Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 20545be7fc build: QQQ 4.1.0-RC.19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:19:12 -05:00
james.maesandClaude Opus 5.5 21671b3df3 fix(schedules): run schedules and watches as the data-analysis service principal
The self-made data-analysis-scheduler session is rejected under OAuth2,
so every scheduled run failed. With the host's principal declared,
inventory runs as it and owner/recipient runs go on their behalf through
it (current access intersected). Undeclared: unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:27:23 -05:00
james.maes 25d03c1514 Merge pull request #17 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:12:58 -05:00
james.maesandClaude Opus 5.5 442eb4398c chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:05 -05:00
james.maesandClaude Opus 5.5 3aa728d88a feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:37 -05:00
james.maes 51ab908b11 Merge pull request #16 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 3474b0c188 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-data-analysis.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:33:59 -05:00
james.maes b12cadfc75 Merge pull request #15 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 6065d2d1c1 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:06:05 -05:00
james.maes 0e0cc0b83d Merge pull request #13 from QRun-IO/feature/help-bundle
feat(help): qbit-data-analysis help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
14 changed files with 1354 additions and 29 deletions

No files matched your search

+7
View File
@@ -4,6 +4,11 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
## [Unreleased]
### Fixed
- Scheduled analyses and watches work under OAuth2. When the host declares the `data-analysis` service principal (QQQ service identities, `DataAnalysisQBitConfig.withServicePrincipal`), schedule and watch inventory runs as that principal, and owner and recipient runs go on their behalf through it: their current access intersected with the principal's. The self-made `data-analysis-scheduler` session was rejected under OAuth2 (`Invalid session in request` on every `data_analysis_run_schedules` run). With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered build resource.
### Added
- Complete host-neutral in-app help bundle with live metadata references and a core help coverage gate.
@@ -42,6 +47,8 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Changed
- UI API extension data-analysis 1.5.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, contract validation for operationIds and schema references, and HTTP error envelope regression assertions.
- Pin QQQ 4.1.0-RC.7 and replace the temporary help loader with core bundle registration and startup sync.
- Boolean pivot/crosstab headers now say Yes / No / (Blank). Pivot measures use human labels such as Count, Average Amount, Sum of Amount, Distinct Email, Median Amount and 90th percentile of Amount.
+29 -1
View File
@@ -128,7 +128,35 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-data-analysis.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-data-analysis.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -215,7 +243,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -427,6 +427,28 @@ public class DataAnalysisQBitConfig implements QBitConfig
return (this);
}
private String servicePrincipal = "data-analysis";
/*******************************************************************************
** The service principal schedules and watches run as when the host declares
** it (QQQ service identities): inventory as the principal, owner and
** recipient runs on their behalf through it. Undeclared: the previous local
** sessions.
*******************************************************************************/
public String getServicePrincipal()
{
return (servicePrincipal);
}
/*******************************************************************************
** Fluent setter for servicePrincipal (default data-analysis).
*******************************************************************************/
public DataAnalysisQBitConfig withServicePrincipal(String servicePrincipal)
{
this.servicePrincipal = servicePrincipal;
return (this);
}
private String messagingProviderName;
private String fromAddress;
private transient java.util.function.Function<String, String> recipientAddressSupplier;
@@ -21,6 +21,9 @@
package com.kingsrook.qbits.dataanalysis;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
@@ -40,13 +43,41 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-data-analysis";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private DataAnalysisQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = DataAnalysisQBitProducer.class.getClassLoader().getResourceAsStream("qbit-data-analysis.properties"))
{
if(inputStream == null)
{
throw (new IllegalStateException("Missing qbit-data-analysis.properties; build with Maven to generate the artifact version"));
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw (new IllegalStateException("Missing or unfiltered version in qbit-data-analysis.properties; build with Maven resource filtering"));
}
return (version);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not read artifact version from qbit-data-analysis.properties", e));
}
}
/*******************************************************************************
** Fluent configuration setter.
*******************************************************************************/
@@ -76,7 +76,7 @@ public class DataAnalysisApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("data-analysis").withSemanticVersion("1.4.0")
return (new MiddlewareSpecExtension().withName("data-analysis").withSemanticVersion("1.5.0")
.withDescription("Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user.")
.withSpec(SavedApi.Run.class).withSpec(SavedApi.ListSaved.class).withSpec(SavedApi.Star.class).withSpec(SavedApi.Unstar.class)
.withSpec(SavedApi.Sql.class).withSpec(AnalysisFileSpec.class).withSpec(AnalysisFileSpec.Saved.class)
@@ -0,0 +1,90 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.schedule;
import java.util.function.Supplier;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
/*******************************************************************************
** Sessions for scheduled analysis work. With the host's data-analysis service
** principal declared (QQQ service identities): inventory runs as the
** principal, and owner or recipient runs go on their behalf through it (their
** current access intersected with the principal's). Without it: the previous
** local sessions (single-node default unchanged). Never a system session.
*******************************************************************************/
public final class AnalysisSessions
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AnalysisSessions()
{
}
/*******************************************************************************
** The session for schedule/watch inventory and status updates.
*******************************************************************************/
public static QSession maintenance(DataAnalysisQBitConfig config, Supplier<QSession> legacy) throws QException
{
if(usesPrincipal(config))
{
return (ServiceIdentities.session(QContext.getQInstance(), config.getServicePrincipal()));
}
return (legacy.get());
}
/*******************************************************************************
** The session for running as a user (owner or recipient).
*******************************************************************************/
public static QSession forUser(DataAnalysisQBitConfig config, String userId) throws QException
{
if(config.getOwnerSessionSupplier() != null)
{
return (config.getOwnerSessionSupplier().apply(userId));
}
if(usesPrincipal(config))
{
return (ServiceIdentities.sessionOnBehalfOf(QContext.getQInstance(), config.getServicePrincipal(), userId));
}
return (new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), userId));
}
/*******************************************************************************
** Whether the host declared the configured principal.
*******************************************************************************/
private static boolean usesPrincipal(DataAnalysisQBitConfig config)
{
return (config.getServicePrincipal() != null && ServiceIdentities.isDeclared(QContext.getQInstance(), config.getServicePrincipal()));
}
}
@@ -58,7 +58,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
/*******************************************************************************
** Scheduled work uses owner and recipient sessions; maintenance only inventories rows.
@@ -83,13 +82,17 @@ public class ScheduleRunner
*******************************************************************************/
public synchronized Integer runDue(Instant now) throws QException
{
QSession maintenance = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
.withPermissions("data_analysis.saved.read", "data_analysis.saved.edit")
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
if(QContext.getQSession().getBackendVariants() != null)
QSession maintenance = AnalysisSessions.maintenance(config, () ->
{
maintenance.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
}
QSession legacy = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
.withPermissions("data_analysis.saved.read", "data_analysis.saved.edit")
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
if(QContext.getQSession().getBackendVariants() != null)
{
legacy.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
}
return (legacy);
});
int[] count = {0};
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), maintenance), () ->
{
@@ -161,9 +164,7 @@ public class ScheduleRunner
QSession session;
try
{
session = config.getOwnerSessionSupplier() == null
? new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), id)
: config.getOwnerSessionSupplier().apply(id);
session = AnalysisSessions.forUser(config, id);
}
catch(Exception e)
{
@@ -49,7 +49,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
/*******************************************************************************
** Transition watches through the core alerts SPI, computing exclusively as owner.
@@ -94,13 +93,17 @@ public class WatchEvaluator
*******************************************************************************/
public synchronized Integer evaluateEnabled(Instant now) throws QException
{
QSession maintenance = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
.withPermissions("data_analysis.watch.read", "data_analysis.watch.edit")
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
if(QContext.getQSession().getBackendVariants() != null)
QSession maintenance = com.kingsrook.qbits.dataanalysis.schedule.AnalysisSessions.maintenance(config, () ->
{
maintenance.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
}
QSession legacy = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
.withPermissions("data_analysis.watch.read", "data_analysis.watch.edit")
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
if(QContext.getQSession().getBackendVariants() != null)
{
legacy.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
}
return (legacy);
});
int[] count = {0};
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), maintenance), () ->
{
@@ -128,9 +131,7 @@ public class WatchEvaluator
QSession session;
try
{
session = config.getOwnerSessionSupplier() == null
? new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), owner)
: config.getOwnerSessionSupplier().apply(owner);
session = com.kingsrook.qbits.dataanalysis.schedule.AnalysisSessions.forUser(config, owner);
}
catch(Exception e)
{
@@ -1,5 +1,5 @@
bundle: qbit-data-analysis
version: 0.1.0-SNAPSHOT
version: ${project.version}
layer: QBIT
entries:
- key: widget:data_analysis_saved_to_data_analysis_saved_star;slot:label
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
version=${project.version}
@@ -136,6 +136,13 @@ class DataAnalysisApiTest extends EngineFixture
}
var response = invoker.invoke(request);
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(status);
if(status >= 400)
{
for(String field : List.of("error", "code", "message", "traceId"))
{
assertThat(response.getJson().path(field).asText()).as(field).isNotBlank();
}
}
return (response.getJson());
}
@@ -282,7 +289,7 @@ class DataAnalysisApiTest extends EngineFixture
}
/*******************************************************************************
** Generated fragment is committed verbatim and has all fourteen operations.
** Generated fragment matches the committed contract and validates all references.
*******************************************************************************/
@Test
void contract() throws Exception
@@ -296,8 +303,9 @@ class DataAnalysisApiTest extends EngineFixture
Files.createDirectories(path.getParent());
Files.writeString(path, yaml);
}
assertThat(Files.exists(path)).as("Regenerate with ./mvnw -o test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT").isTrue();
assertThat(Files.exists(path)).as("Regenerate with ./mvnw -o test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.99-SNAPSHOT").isTrue();
assertThat(Files.readString(path).replace("\r\n", "\n")).as("OpenAPI changed: regenerate with -Dopenapi.write=true").isEqualTo(yaml.replace("\r\n", "\n"));
assertThat(UiApiContract.check(yaml, Files.readString(path), null, 1, "-Dopenapi.write=true")).isEmpty();
}
@Test
void pivotHttp() throws Exception
@@ -364,7 +372,7 @@ class DataAnalysisApiTest extends EngineFixture
}
relationshipCountDrills(call(HttpMethod.POST, "/tables/{table}/crosstab", Map.of("rowField", "status", "columnField", "customer"), 200));
// All new schemas are discoverable from the same served OpenAPI catalog.
assertThat(com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi.extension().getSemanticVersion()).isEqualTo("1.4.0");
assertThat(com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi.extension().getSemanticVersion()).isEqualTo("1.5.0");
}
/*******************************************************************************
@@ -116,11 +116,15 @@ class DataAnalysisMetadataTest
assertThat(producer.isEnabled()).isTrue();
assertThat(producer.getNamespace()).isEqualTo("analysis");
assertThat(producer.getQBitConfig()).isSameAs(config);
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
String artifactVersion = System.getProperty("qbit.artifactVersion");
assertThat(artifactVersion).isNotBlank();
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(artifactVersion).isEqualTo(DataAnalysisQBitProducer.VERSION);
assertThat(producer.getQBitMetaData().getGroupId()).isEqualTo("com.kingsrook.qbits");
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-data-analysis");
assertThat(producer.getPackageNameForFindingMetaDataProducers()).endsWith(".metadata");
producer.produce(instance);
assertThat(HelpBundles.of(instance).getBundles().getFirst().getVersion())
.isEqualTo(artifactVersion).isEqualTo(producer.getQBitMetaData().getVersion());
assertThat(HelpBundles.of(instance).getBundles().getFirst().getEntries()).anyMatch(entry -> entry.getKey().equals("app:customAnalysis;slot:help"))
.noneMatch(entry -> entry.getKey().equals("app:dataAnalysis;slot:help"));
assertThat(instance.getApp("customAnalysis").getParentAppName()).isEqualTo("admin");
@@ -26,6 +26,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qbits.dataanalysis.processes.ScheduleActions;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
@@ -47,6 +48,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProvide
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -160,6 +163,27 @@ class SchedulesTest extends EngineFixture
assertThat(WatchesTest.Sink.raised).isEmpty();
}
/*******************************************************************************
** With the host's data-analysis principal declared, schedule inventory runs
** as the principal (its grants decide - a principal without them finds
** nothing due), never as a self-made session.
*******************************************************************************/
@Test
void inventoryRunsAsTheDeclaredPrincipal() throws Exception
{
schedule("PIVOT", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT"))), "CSV");
config.withServicePrincipal("analysis-bare");
ServiceIdentitiesMetaData.declare(instance, new QServicePrincipalMetaData().withName("analysis-bare"));
assertThatThrownBy(() -> runner.runDue(NOW)).isInstanceOf(QException.class);
assertThat(Mail.sent).isEmpty();
ServiceIdentitiesMetaData.declare(instance, new QServicePrincipalMetaData().withName("data-analysis")
.withLocalPermissions("data_analysis.saved.read", "data_analysis.saved.edit").withLocalSecurityKeyValue(DataAnalysisPermissions.ADMIN, true));
config.withServicePrincipal("data-analysis");
assertThat(runner.runDue(NOW)).isEqualTo(1);
assertThat(Mail.sent).hasSize(2);
}
@Test
void providerFailureAndMissingOwnerRaiseVisibleAlerts() throws Exception
{