Compare commits

...
Author SHA1 Message Date
CircleCI eeec501834 Bump version to 0.1.0-RC.2 [skip ci] 2026-10-14 23:02:37 +00:00
james.maesandClaude Opus 5.5 a9b4f076f9 Merge develop into release/0.1 for 0.1.0-RC.2 (#12 tables work)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:57:17 -05:00
james.maes e11e1464cd Merge pull request #12 from QRun-IO/feature/tables-populated-defaults
feat: Create rows for analyzable tables, Setup card and table help
2026-10-14 17:47:00 -05:00
james.maesandClaude Opus 5.5 bab162e77c fix: accurate Result cache line, count-only summary, action visible only with settings insert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:43:27 -05:00
james.maesandClaude Opus 5.5 cdce36d119 chore: well-formed STAND-IN marker for the help applier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:30:33 -05:00
james.maesandClaude Opus 5.5 04d3c01cf3 feat: Create rows for analyzable tables, Setup card and table help so Analysis Settings is never a mystery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:23:24 -05:00
james.maes 69800f755c Merge pull request #11 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:45:55 -05:00
james.maesandClaude Opus 5.5 f8d1ed787c ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:06:44 -05:00
CircleCI de205edfeb Bump version to 0.1.0-RC.1 [skip ci] 2026-10-14 12:20:07 +00:00
james.maes 8bda02bc4a Merge pull request #10 from QRun-IO/feature/saved
feat(saved): saved, shared, starred, scheduled and widget analyses, export, SQL handoff
2026-10-14 07:10:01 -05:00
james.maes 93ebd64fbe Merge pull request #9 from QRun-IO/feature/watches
feat(watches): anomaly watches through the alerts SPI, contingent pickers
2026-10-14 07:09:47 -05:00
james.maes a157f18edc Merge pull request #8 from QRun-IO/feature/compare
feat(compare): compare, distribution shift, correlation, scatter and crosstabs
2026-10-14 07:09:35 -05:00
james.maes 894cc98d9b Merge pull request #6 from QRun-IO/feature/pivot
feat(pivot): pivot / group-by explorer and the field-access resolver seam
2026-10-14 07:09:21 -05:00
james.maes 2c52814e33 Merge pull request #5 from QRun-IO/feature/profile-endpoints
feat(api): /qqq/v1 data-analysis extension, core pushdown backend, analyze permission
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 69154de566 fix(tables): a new saved analysis, star, watch or run belongs to the inserting user
Found driving the Next UI against the sample app: the UI cannot send the
user's id, and core checks required fields and record locks before the
customizers run. An insert-only field behavior fills empty owner/user
fields with the session user (never on update, so an administrator's
partial edit cannot take ownership); naming another owner is still
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:49:18 -05:00
james.maes b3c8a0412a Merge remote-tracking branch 'origin/feature/watches' into feature/saved 2026-10-13 22:26:08 -05:00
james.maesandClaude Opus 5.5 1641b32296 fix(stats): pool sparse crosstab categories instead of refusing the test
Chi-square now pools the smallest category into Other one at a time until
every expected count is at least five (rows too, for independence), so a
crosstab with a single-record row still gets a valid test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:14:14 -05:00
james.maesandClaude Opus 5.5 d2c9871d98 feat(saved): saved, shared, starred, scheduled and widget analyses, export, SQL handoff
Extension 1.4.0: saved analyses of every kind re-run as the viewer
(fields they cannot analyze are dropped), owner-or-shared READ through
core record locks with owner-only writes (and a database CHECK so a
private row can never lose its read owner), personal stars, schedules
that run as the owner and mail only recipients who could run the analysis
themselves, CSV/XLSX export, the SQL Workbench handoff, an analysis
dashboard widget, and an Analyses home built from standard widgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:23:23 -05:00
55 changed files with 5229 additions and 337 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
+11
View File
@@ -6,6 +6,17 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Added
- Analysis Settings table action **Create rows for analyzable tables**: writes the effective settings as editable `data_analysis_table_config` rows (insert-only, no change in behavior).
- Setup card on the Analyses home (settings, result cache, saved analyses, watches, schedules).
- Help bundle `help/qbit-data-analysis.yaml`: every table says what it is for and how its rows get created.
- `DataAnalysisTablesGuardrailTest` and `TableConfigDefaultsTest` (including a test that a settings row's `cache_ttl_minutes` changes result caching).
- Phase f saved execution for all seven kinds, core owner-or-shared READ with owner-only WRITE, typed definition validation and viewer-specific field reduction.
- Idempotent personal star endpoints and visible saved list; transient viewer runs, core CSV/XLSX downloads and QueryStat-backed SQL Workbench handoff.
- Five-minute owner-session schedule runner, per-recipient FULL access checks and record-scoped reruns, core messaging with attachments, explicit failure statuses/alerts, timezone/weekly/monthly/cron timing and streamed ETL Send now.
- Standard statistics/chart/table dashboard widgets, linked saved analysis widgets and real home rows with narrow tables and honest empty states.
- Extension 1.4.0, seven new assistant-discoverable endpoints, regenerated OpenAPI and memory/H2/HTTP/export/email/security regression coverage.
- Migration requirement: backfill saved `read_owner_user_id` before enabling shared reads and widen schedule `last_status` to TEXT. Stock core EMAIL attachment limitations fail visibly; supported messaging providers receive full attachments.
- Phase e anomaly watches: owner-locked configuration, validated metrics/detector parameters, complete calendar buckets, five pure detectors, and scheduled evaluation through owner sessions.
- Core alerts SPI type `data.analysis.anomaly` (core disallows the spec's underscore), stable watch keys, relative drill links, raise/recovery transitions, and persisted outcomes without an alerts implementation.
- Standard streamed ETL Evaluate now / Enable / Disable record actions and firing-first linked watches on the Analyses home.
+66 -5
View File
@@ -6,7 +6,7 @@ Statistical analysis and data dives on any QQQ table, filter or saved view: prof
The host API provides column and table profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs, distinct-value pages, duplicate groups, drill-through filters, privacy suppression, and asynchronous exact profile runs with a persistent per-user cache. Metadata, six visible tables, owner locks, PostgreSQL DDL generation and the Analyses home are also available.
Fourteen HTTP operations are available under `/qqq/v1/qbit/data-analysis`; the Analyze UI is a later phase. Pivot and relationship backends are available. Anomaly watches, their scheduled evaluator and alert transitions are available. Saved-analysis execution, scheduled delivery and exports remain later phases.
Twenty-one HTTP operations are available under `/qqq/v1/qbit/data-analysis` (extension **1.4.0**). Saved execution, shared reads, personal stars, scheduled email, CSV/XLSX exports, SQL Workbench handoff and standard dashboard widgets are implemented. The separate Analyze/pivot/relationship frontend remains outside this repository.
## Requirements
@@ -32,6 +32,16 @@ new DataAnalysisQBitProducer()
The producer installs metadata into the instance. It scans only `com.kingsrook.qbits.dataanalysis.metadata`; helpers and renderers are not scanned as component producers. Set `withParentAppName("admin")` to nest the app under an existing parent.
## Starter defaults and setup
Data Analysis ships no SQL seed: `data_analysis_table_config` depends on which tables the host has and on the code configuration (`defaultMaxRows`, `tableDefaults`, `includeTables`, `excludeTables` and the other defaults), so its rows are computed at run time.
- With no rows, every analyzable table is on with the configured defaults. That is a complete, working setup.
- Open Analysis Settings and run the table action **Create rows for analyzable tables**. It adds one row per analyzable table that has none, holding the settings that already apply (table defaults over global defaults). Nothing changes until you edit a row. It is insert-only: it never touches an existing row, and running it again only adds rows for tables added since.
- Rows pin their values: after a row exists, later changes to the code defaults do not reach that table, edit the row instead. `cache_ttl_minutes` is what the exact-run result cache uses for that table (0 turns caching off); `TableConfigDefaultsTest` proves a row changes caching.
- The **Setup** card on the Analyses home lists settings, result cache, saved analyses, watches and schedules with their state and next step, using the same resolution the runtime uses.
- Every table has help (shown when it is empty) that says how its rows get created. Saved analyses, stars, schedules, watches and runs are user content or logs and start empty by design.
## Tables
QQQ names and physical SQL table names are identical. `backendName` in configuration identifies the backend connection; it is not a SQL table name. All tables have `id`, `createDate` and `modifyDate`; SQL columns use inferred snake_case names.
@@ -66,7 +76,7 @@ The four `table_name` fields use core's `tables` possible-value source; an exist
Every engine operation and home count runs under the current `QContext` session with `PermissionEnforcement.ENFORCE`. A denied section explains the missing permission instead of showing zero. Mine and starred counts explicitly filter the current user even for administrators. Shared counts include only readable shared rows. Stars pointing at unreadable or deleted saved analyses do not count. Run counts cover starts within the last seven days, excluding future rows.
Saved analyses have a READ_AND_WRITE lock on `owner_user_id`; stars and runs have locks on `user_id`. Schedules inherit the saved analysis owner's lock through their join. Null owners are denied. The host's authentication/session adapter must supply:
Saved analyses have a WRITE lock on `owner_user_id` and a separate READ lock on the hidden `read_owner_user_id` projection. Core pre-insert/update customizers set that projection to the owner for private rows and null for shared rows; the READ lock allows null. This expresses **owner OR shared READ**, while core retains owner-only writes and administrator all-access keys. Stars and runs have locks on `user_id`. Schedules inherit the saved analysis owner's lock through their join. Null owners are denied. The host's authentication/session adapter must supply:
```java
session.withSecurityKeyValue("data_analysis_user", session.getUser().getIdReference());
@@ -77,7 +87,18 @@ session.withPermission("data_analysis.admin")
QQQ permissions and security-key values are separate. An admin permission alone does not bypass record locks. User-id fields must use the same stable string identity as `QUser.idReference`.
**Shared READ is deliberately restricted in this scaffold.** The ordinary owner lock does not express owner OR shared while keeping writes owner-only, so setting `shared=true` currently does not expose a saved analysis to other users. Phase f will introduce separate read and write policies: reads permit owner OR shared only after checking `data_analysis.saved.read`; writes retain owner/admin checks. Query, get, count, joins, child lists and exports will receive cross-user regression tests before enabling shared READ. Collaboration sharing and favorites (§7.3, qqq#1136) will then replace the local sharing/star contracts. Shared analyses must always execute as their viewer.
**Shared READ is enabled through core record locks.** A viewer still needs `data_analysis.saved.read`. Query/get/count, standard CRUD, saved runs, lists and exports use core enforcement. Shared viewers cannot edit, delete, change ownership, or schedule another owner's analysis. No custom query filter grants access. Sharing exposes the saved definition and metadata, not the owner's data: every run computes under the viewer's source-table READ/Analyze permissions and field policy. There is no per-recipient/group sharing or access-request workflow yet; collaboration's sharing contract ([qqq#1136](https://github.com/QRun-IO/qqq/issues/1136)) will replace the local flag. Stars move to `collaboration_favorite` when collaboration ships.
**Existing databases must migrate before enabling the new metadata.** A missing/null read-owner projection would make an old private row readable. Apply this through the host's migration system while writes are stopped, before deploying this version:
```sql
ALTER TABLE data_analysis_saved ADD COLUMN IF NOT EXISTS read_owner_user_id VARCHAR(250);
UPDATE data_analysis_saved
SET read_owner_user_id = CASE WHEN shared IS TRUE THEN NULL ELSE owner_user_id END;
ALTER TABLE data_analysis_schedule ALTER COLUMN last_status TYPE TEXT;
```
The metadata-generated create script includes these fields for new installations; it does not migrate existing databases. Fix older placeholder definitions (such as `{}` for a column profile) to valid typed requests. Edit existing enabled schedules through core CRUD to validate recipients and initialize `next_run_at`.
Minimum group size defaults to 0 (off). Hosts should set it for personal-data tables. Groups, bins and distinct listings below N are merged into an Other row with no value or drill. Min/max, percentiles, mean and standard deviation are suppressed when the assessed population is below N. Outlier listings and their count's drill are removed whenever the guard is enabled. The guard runs before chart construction and before `result_json` is stored.
@@ -233,15 +254,55 @@ A transition from OK to FIRING raises through core `QAlerts`, with the watch rec
`DataAnalysisSchema.postgresScript(config)` generates the new fields and owner requirement. Existing installations must add `date_field VARCHAR(250)`, `owner_user_id VARCHAR(250)`, `last_value NUMERIC`, and `last_message TEXT`, then backfill each watch's actual owner/date field before making the first two columns NOT NULL and enabling evaluation. The CREATE-IF-NOT-EXISTS generator does not migrate existing tables.
## Saved analyses, stars and exports
Create/edit saved analyses through ordinary core table CRUD. `kind` selects `COLUMN_PROFILE`, `TABLE_PROFILE`, `PIVOT`, `COMPARE`, `CORRELATION`, `CROSSTAB` or `SHIFT`; `definition_json` stores that kind's typed request. For example, a column profile has `{"fieldName":"amount","filter":{...}}`, and a pivot has `{"rows":[{"field":"status"}],"values":[{"operator":"COUNT"}]}`. `table_name` identifies the source table; a supplied `tableName` in the JSON must match. The customizers reject malformed JSON, missing selections, nonexistent tables/fields and invalid partial edits with plain record errors. A typed definition filter takes precedence over legacy `filter_json`.
`POST /saved/{id}/run` re-runs as the viewer and returns `{kind, warnings, columnProfile, tableProfile, pivot, compare, correlation, crosstab, shift}`, with the matching typed member populated. Selected fields without FULL analysis access are removed and `Some fields are not available to you` is returned. If a required axis, all required selections, or a filter predicate is unavailable, no computation is performed and the typed members are null; filters are never dropped to broaden the population. Saved profile runs are transient, so they do not require run-table write permission and cannot reuse an owner's cache.
`GET /saved` lists core-visible saved metadata with each viewer's `starred` flag. `PUT /saved/{id}/star` and `DELETE /saved/{id}/star` are idempotent and always target the signed-in user, using standard saved INSERT/DELETE grants. The local star table remains available through ordinary CRUD and has a unique `(saved_id,user_id)` key.
`GET /saved/{id}/export?format=csv|xlsx` re-runs as the viewer. `POST /export` accepts a discriminated request:
```json
{"kind":"PIVOT","tableName":"orders","request":{"rows":[{"field":"status"}],"values":[{"operator":"COUNT"}]},"format":"xlsx"}
```
Both routes return file bytes with attachment headers. Core `ReportFormat` CSV/XLSX streamers write computed result tables: profile metrics/top values, pivot grids, comparisons, correlation/crosstab tables and shift buckets. Values retain their formatting and privacy suppression. Warnings are included in downloads. All paths above are relative to `/qqq/v1/qbit/data-analysis` and appear in assistant operation discovery.
## Scheduled delivery
Configure `schedulerName`, `messagingProviderName`, `fromAddress`, `ownerSessionSupplier` and `recipientAddressSupplier` in `DataAnalysisQBitConfig`. The session supplier resolves both owners and recipients by their stable user id; without it, the authentication module's `createAutomatedSessionForUser` is used. The address supplier maps those ids to verified directory email addresses; schedule recipients are **user ids**, never arbitrary email addresses.
Schedules use `recipients` as a JSON array (1–100 ids), `format` as `HTML_SUMMARY`, `CSV` or `XLSX`, and either Quartz cron (including the seconds field) or `frequency` + `day` + `time` + `time_zone`. Weekly days are ISO Monday=1 through Sunday=7; monthly days are 1–31, clamped to the last day of shorter months. Times are local ISO times such as `09:00`; omitted time zone is UTC. Spring-forward gaps move forward, and the first occurrence is chosen in an autumn overlap. `next_run_at` is strictly after the evaluation time.
The hidden `data_analysis_run_schedules` process repeats every 300 seconds when a scheduler is configured. Its scheduler session needs `data_analysis_run_schedules.hasAccess`. Inventory/status operations use a narrow maintenance session; source computation always switches to the saved owner. Every recipient must resolve to the correct identity and pass source READ, Analyze and FULL access for all selected/filter fields. Different recipients receive a fresh computation under their own record locks. Skipped ids appear in `last_status`.
Email includes escaped HTML headline metrics and a result table (first 20 rows for pivots), with the requested CSV/XLSX attachment via core `SendMessageAction`. `HTML_SUMMARY` has no attachment. Missing messaging configuration and delivery errors persist an explicit failure and raise the declared **`data.analysis.schedule.failed`** alert. Core rejects underscores in alert names, so the spec's `data_analysis.schedule_failed` spelling cannot be registered. With no alert sink, core logs the signal and the status remains visible.
The installed core's stock `EMAIL` provider silently ignores `SendMessageInput.attachmentList`. CSV/XLSX schedules therefore reject that provider with an explicit status/alert; configure an attachment-capable core messaging provider. A test provider verifies the complete message and attachment contract. This qbit does not alter core's SMTP transport.
**Send now** uses the standard streamed ETL Review/Result record action. Reviews do not deliver; execution reauthorizes and reports actual outcomes. Disabled schedules can be sent manually. A runtime serializes scheduled and manual attempts, but there is no cross-node transaction or exactly-once email guarantee; run the scheduler on one node. New configurations compute their next occurrence; failed attempts advance to the next occurrence instead of retrying every five minutes.
## SQL Workbench and dashboard widgets
`POST /tables/{table}/sql` accepts `{kind,request}` and returns `{available,sql,note}`. It requires an installed SQL Workbench app/table and the configured app access permission (default `sql_workbench.hasAccess`), plus source permissions. Memory/non-RDBMS backends report unavailable. The engine executes the request as the viewer and reads the actual main aggregate's core `QueryStat`; grouped dimensions and the widest metric batch identify the main statement. Bound `?` placeholders remain, and the note states that QQQ applies record security at runtime and the SQL is for reading.
Core exposes `QueryStat` on the backend but not the backend reference on `AggregateAction`. A narrow read-only reflective bridge accesses that reference after normal core execution. Query-stat capability is enabled only on the request's cloned table metadata; no global logger or statistics-manager settings change. If core changes that private field or does not provide SQL, the endpoint reports unavailable rather than fabricating a statement.
The Analyses home uses standard `multiStatistics` and `table` widgets: mine/shared/starred counts, enabled/firing watches, my runs in the last seven days, starred analysis links, firing watches and recent run links. All counts and rows use the viewer's session. Tables have at most four columns and use Next's responsive table/card renderer, with explicit empty/permission states.
`SavedAnalysisWidget.widget()` supplies the **`DATA_ANALYSIS_SAVED`** metadata. Clone/rename it for another dashboard and set `.withDefaultValue("savedId", id)`. Its standard chart renderer re-runs as the viewer, shows formatted headline metrics, projects the semantic series/pivot/crosstab into a standard chart, and links back to the saved record. It never displays an owner's cached result. The standard chart is a dashboard summary; the full analysis UI remains responsible for specialized box/heatmap presentations.
## Adopting in another app
1. Add a backend, install the producer and run the usual `QInstanceValidator`.
2. For PostgreSQL, obtain `DataAnalysisSchema.postgresScript(config)` and apply it through your application's migration system. It derives fields and unique indexes from table metadata. It does not execute DDL or upgrade existing schemas.
3. Map user-id security keys and permissions in the authentication adapter. Configure core audit persistence if field audit history is needed.
4. Review per-table include/exclude sets, limits and minimum group sizes before enabling analysis. Integrate Saved Analyses and Watches with host ESB/Quick Search when those optional host features are installed; this scaffold does not add those dependencies.
5. Use the Analyses home as the owner of analysis data. Other dashboards should link there until saved-analysis widgets are implemented.
5. Use the Analyses home as the owner of analysis data. Other dashboards can place a configured DATA_ANALYSIS_SAVED widget linked to its saved record.
Phase **b** profiles, drill-through, runs/cache and HTTP endpoints, phase **c** pivot backend, phase **d** compare/relationships backend, and phase **e** anomaly watches are implemented. Remaining work: **b** analysis UI; **c** pivot UI; **d** comparison/relationship UI; **f** saved-analysis execution, sharing, stars integration, schedules, chart widgets, full home and exports. The current home summarizes stored rows; the host facade executes profiles. The home does not send deliveries.
Backend phases **b–f** are implemented, including saved/shared/starred analyses, schedules, standard widgets, home, export and SQL handoff. The separate profile, pivot and relationship frontend remains outside this repository.
## Build
+8
View File
@@ -0,0 +1,8 @@
# Upgrading
## Unreleased
No schema change and nothing required of hosts.
- New table action **Create rows for analyzable tables** on Analysis Settings. Run it once if you want the effective settings visible and editable as rows. It changes no behavior, is insert-only, and only adds rows for tables that have none. After a row exists it wins over later changes to the code defaults for that table.
- New **Setup** widget first on the Analyses home, and table help text. Hosts that override help on a table keep their own.
+1 -1
View File
@@ -2,7 +2,7 @@
Approved specification: [QQQ docs/DATA-ANALYSIS.md](https://github.com/QRun-IO/qqq/blob/develop/docs/DATA-ANALYSIS.md), introduced in [PR #1137](https://github.com/QRun-IO/qqq/pull/1137).
Data Analysis will provide profiles, pivots, comparisons, relationships and anomaly watches on existing QQQ data. It will push supported computation to the backend, use bounded streamed fallbacks, enforce the viewer’s permissions and privacy limits, and store configuration, saved definitions, schedules and run history in ordinary visible tables. This repository phase supplies those tables, permissions, configuration, PostgreSQL schema generation and an Analyses home with real session-scoped counts; analytical computation and delivery are planned.
Data Analysis will provide profiles, pivots, comparisons, relationships and anomaly watches on existing QQQ data. It will push supported computation to the backend, use bounded streamed fallbacks, enforce the viewer’s permissions and privacy limits, and store configuration, saved definitions, schedules and run history in ordinary visible tables. Backend phases b–f are implemented here: typed analysis engines, saved/shared/starred definitions, schedules, exports, SQL handoff and standard widgets with session-scoped data. Specialized analysis frontend work lives in Next. README documents the required shared-read migration and the installed core transport limitations.
## Phases (spec §11)
+1 -1
View File
@@ -18,7 +18,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<revision>0.1.0-RC.2</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -426,4 +426,59 @@ public class DataAnalysisQBitConfig implements QBitConfig
ownerSessionSupplier = supplier;
return (this);
}
private String messagingProviderName;
private String fromAddress;
private transient java.util.function.Function<String, String> recipientAddressSupplier;
/*******************************************************************************
** Core messaging provider used for scheduled delivery.
*******************************************************************************/
public String getMessagingProviderName()
{
return (messagingProviderName);
}
/*******************************************************************************
** Select the host's configured SMTP or SES provider.
*******************************************************************************/
public DataAnalysisQBitConfig withMessagingProviderName(String value)
{
messagingProviderName = value;
return (this);
}
/*******************************************************************************
** Verified sender address supplied by the host.
*******************************************************************************/
public String getFromAddress()
{
return (fromAddress);
}
/*******************************************************************************
** Set the scheduled email sender.
*******************************************************************************/
public DataAnalysisQBitConfig withFromAddress(String value)
{
fromAddress = value;
return (this);
}
/*******************************************************************************
** Resolve a user id to its current email; never treat recipients as addresses.
*******************************************************************************/
public java.util.function.Function<String, String> getRecipientAddressSupplier()
{
return (recipientAddressSupplier);
}
/*******************************************************************************
** Bind the host directory's user-to-email lookup.
*******************************************************************************/
public DataAnalysisQBitConfig withRecipientAddressSupplier(java.util.function.Function<String, String> value)
{
recipientAddressSupplier = value;
return (this);
}
}
@@ -144,6 +144,13 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
.withResolveMode(com.kingsrook.qqq.backend.core.alerts.ResolveMode.PRODUCER)
.withSubjectTableName(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.WATCH)
.withVisibilityPermission("data_analysis.watch.read"));
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(instance).withAlertType(
new com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData().withName(com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner.ALERT_TYPE)
.withLabel("Analysis schedule failed").withSourceName(ARTIFACT_ID).withGroupName("data-analysis")
.withDefaultSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR)
.withResolveMode(com.kingsrook.qqq.backend.core.alerts.ResolveMode.PRODUCER)
.withSubjectTableName(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.SCHEDULE)
.withVisibilityPermission("data_analysis.saved.read"));
var included = java.util.Set.copyOf(qBitConfig.getIncludeTables());
var excluded = java.util.Set.copyOf(qBitConfig.getExcludeTables());
TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(new TableFeaturePermission()
@@ -164,6 +171,7 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
{
instance.getApp(qBitConfig.getParentAppName()).withChild(instance.getApp(qBitConfig.getAppName()));
}
com.kingsrook.qbits.dataanalysis.help.DataAnalysisHelp.apply(instance, qBitConfig);
return (new MetaDataProducerMultiOutput());
}
}
@@ -30,6 +30,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaDa
public class DataAnalysisRuntime implements QSupplementalInstanceMetaData
{
private final transient DataAnalysisService service;
private final transient com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner schedules;
private final transient com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses saved;
private final transient com.kingsrook.qbits.dataanalysis.watch.WatchEvaluator watches;
/*******************************************************************************
@@ -38,6 +40,8 @@ public class DataAnalysisRuntime implements QSupplementalInstanceMetaData
public DataAnalysisRuntime(DataAnalysisQBitConfig config)
{
service = new DataAnalysisService(config);
schedules = new com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner(config, service);
saved = new com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses(service);
watches = new com.kingsrook.qbits.dataanalysis.watch.WatchEvaluator(config, service);
}
@@ -80,4 +84,20 @@ public class DataAnalysisRuntime implements QSupplementalInstanceMetaData
{
return (((DataAnalysisRuntime) QContext.getQInstance().getSupplementalMetaData("dataAnalysisRuntime")).watches);
}
/*******************************************************************************
** Saved operations share the instance's engine and serialize star toggles.
*******************************************************************************/
public static com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses saved()
{
return (((DataAnalysisRuntime) QContext.getQInstance().getSupplementalMetaData("dataAnalysisRuntime")).saved);
}
/*******************************************************************************
** Shared schedule runner for manual and automatic delivery.
*******************************************************************************/
public static com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner schedules()
{
return (((DataAnalysisRuntime) QContext.getQInstance().getSupplementalMetaData("dataAnalysisRuntime")).schedules);
}
}
@@ -57,6 +57,14 @@ public final class DataAnalysisSchema
columns.add(" " + field.getBackendName() + " " + ("id".equals(field.getName())
? "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : sqlType(field) + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : "")));
}
if(DataAnalysisTables.SAVED.equals(table.getName()))
{
///////////////////////////////////////////////////////////////////////////////
// a null read-owner projection means "shared": make the database refuse it //
// on a private row, so a row written outside QQQ fails closed, not open //
///////////////////////////////////////////////////////////////////////////////
columns.add(" CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared = TRUE OR read_owner_user_id IS NOT NULL)");
}
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
if(table.getUniqueKeys() != null)
{
@@ -351,4 +351,29 @@ public class DataAnalysisService
return (new com.kingsrook.qbits.dataanalysis.watch.WatchSeries(security, configs.resolve(table), table)
.preview(definition, com.kingsrook.qbits.dataanalysis.watch.WatchRecords.filter(resolved), now));
}
/*******************************************************************************
** Saved definitions drop selected fields using the same FULL access resolver.
*******************************************************************************/
public boolean isAvailableField(String table, String field)
{
try
{
if(field.contains("."))
{
String[] parts = field.split("\\.", 2);
security.joinedField(parts[0], parts[1]);
}
else
{
security.field(table, field);
}
return (true);
}
catch(QException e)
{
return (false);
}
}
}
@@ -0,0 +1,195 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.api;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.executors.AbstractMiddlewareExecutor;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareInput;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareOutputInterface;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.openapi.model.Content;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.RequestBody;
import com.kingsrook.qqq.openapi.model.Response;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
import io.javalin.http.Context;
/*******************************************************************************
** Binary endpoints extend AbstractEndpointSpec directly, preserving core sessions.
*******************************************************************************/
public class AnalysisFileSpec extends AbstractEndpointSpec<AnalysisFileSpec.Input, AnalysisFileSpec.Output, AnalysisFileSpec.Executor>
{
/*******************************************************************************
** POST an arbitrary supported typed request plus CSV/XLSX format.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(DataAnalysisApi.PREFIX + "/export").withHttpMethod(HttpMethod.POST)
.withShortSummary("Export an analysis result").withLongDescription("Execute a typed analysis as the signed-in viewer and download its result table as CSV or XLSX. Requires source READ/Analyze and FULL fields; uses core report formats."));
}
/*******************************************************************************
** Saved export has only id/format parameters; ad hoc export uses a JSON body.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of());
}
/*******************************************************************************
** Typed discriminated request plus file format.
*******************************************************************************/
@Override
public RequestBody defineRequestBody()
{
return (new RequestBody().withRequired(true).withContent(Map.of("application/json", new Content()
.withSchema(new Schema().withRef("#/components/schemas/DataAnalysisSavedRequestV1")))));
}
/*******************************************************************************
** Both file representations are declared, without JSON response wrapping.
*******************************************************************************/
@Override
public Map<Integer, Response> defineResponses()
{
Schema file = new Schema().withRef("#/components/schemas/DataAnalysisFileV1");
return (Map.of(200, new Response().withDescription("Analysis result file").withContent(new java.util.TreeMap<>(Map.of("text/csv", new Content().withSchema(file),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new Content().withSchema(file))))));
}
/*******************************************************************************
** Read transport data only; execution waits for middleware session setup.
*******************************************************************************/
@Override
public Input buildInput(Context context) throws Exception
{
Input input = new Input();
if(this instanceof Saved)
{
input.id = SavedApi.id(context.pathParam("id"));
input.format = context.queryParam("format");
}
else
{
if(context.contentType() == null || !context.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/json"))
{
throw new QBadRequestException("The request body must be JSON.");
}
input.body = SavedDefinition.object(context.body());
input.format = input.body.get("format") instanceof String value ? value : null;
}
AnalysisExport.format(input.format);
return (input);
}
/*******************************************************************************
** Actual file bytes, a stable filename and the matching MIME type.
*******************************************************************************/
@Override
public void handleOutput(Context context, Output output)
{
context.contentType(output.file.mimeType()).header("Content-Disposition", "attachment; filename=\"" + output.file.filename() + "\"")
.header("Cache-Control", "no-store").result(output.file.bytes());
}
/*******************************************************************************
** Saved GET variant uses the same file executor.
*******************************************************************************/
public static class Saved extends AnalysisFileSpec
{
/*******************************************************************************
** Core-visible saved definition is re-run as viewer before export.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(DataAnalysisApi.PREFIX + "/saved/{id}/export").withHttpMethod(HttpMethod.GET)
.withShortSummary("Export a saved analysis").withLongDescription("Re-run a visible saved analysis as the viewer, applying current source permissions and dropping unavailable fields, then download its result table as CSV or XLSX."));
}
/*******************************************************************************
** Required id and CSV/XLSX query format.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(new Parameter().withName("id").withIn(In.PATH).withRequired(true).withDescription("Visible saved analysis id.").withSchema(new Schema().withType(Type.INTEGER)),
new Parameter().withName("format").withIn(In.QUERY).withRequired(true).withDescription("Result file format.").withSchema(new Schema().withType(Type.STRING).withEnumValues(List.of("csv", "xlsx")))));
}
/*******************************************************************************
** GET has no request body.
*******************************************************************************/
@Override
public RequestBody defineRequestBody()
{
return (null);
}
}
/*******************************************************************************
** Input data only, inherited middleware fields carry the authenticated session.
*******************************************************************************/
public static class Input extends AbstractMiddlewareInput
{
private Integer id;
private String format;
private Map<String, Object> body;
}
/*******************************************************************************
** Binary output stays out of JSON serialization.
*******************************************************************************/
public static class Output implements AbstractMiddlewareOutputInterface
{
private AnalysisExport.File file;
}
/*******************************************************************************
** Invoked after the normal middleware session pipeline.
*******************************************************************************/
public static class Executor extends AbstractMiddlewareExecutor<Input, Output>
{
/*******************************************************************************
** No direct source access outside the existing authorized analysis engine.
*******************************************************************************/
@Override
public void execute(Input input, Output output) throws QException
{
var result = input.id == null ? SavedApi.definition(input.body, null).run(DataAnalysisRuntime.service(), true)
: DataAnalysisRuntime.saved().run(input.id);
output.file = AnalysisExport.file(result, input.format);
}
}
}
@@ -76,8 +76,10 @@ public class DataAnalysisApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("data-analysis").withSemanticVersion("1.3.0")
return (new MiddlewareSpecExtension().withName("data-analysis").withSemanticVersion("1.4.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)
.withSpec(CapabilitiesSpec.class).withSpec(ColumnProfileSpec.class).withSpec(DistinctValuesSpec.class)
.withSpec(CompareSpec.class).withSpec(ShiftSpec.class).withSpec(CorrelationSpec.class).withSpec(ScatterSpec.class).withSpec(CrosstabSpec.class)
.withSpec(WatchPreviewSpec.class).withSpec(PivotSpec.class).withSpec(TableProfileSpec.class).withSpec(DuplicatesSpec.class).withSpec(StartRunSpec.class).withSpec(GetRunSpec.class));
@@ -463,8 +465,7 @@ public class DataAnalysisApi
result.canAnalyze = false;
result.fields = List.of();
result.backendCapabilities = List.of();
result.sqlWorkbenchAvailable = QContext.getQInstance().getApp("sqlWorkbench") != null
|| QContext.getQInstance().getTables().keySet().stream().anyMatch(t -> t.startsWith("sql_workbench_"));
result.sqlWorkbenchAvailable = com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.installedAndAllowed();
try
{
var capabilities = DataAnalysisRuntime.service().capabilities(name);
@@ -116,6 +116,7 @@ public class DataAnalysisSchemas
result.put("DataAnalysisPivotV1", new DataAnalysisPivotV1().toSchema());
result.putAll(RelationshipSchemas.components());
result.putAll(WatchSchemas.components());
result.putAll(SavedSchemas.components());
return (result);
}
@@ -0,0 +1,208 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.api;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qbits.dataanalysis.saved.SqlHandoff;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.openapi.model.HttpMethod;
/*******************************************************************************
** Saved execution, stars, visible lists and SQL discovery through the UI API.
*******************************************************************************/
public final class SavedApi
{
/*******************************************************************************
** Namespace only.
*******************************************************************************/
private SavedApi()
{
}
/*******************************************************************************
** Stable integer-id errors, without probing inaccessible records.
*******************************************************************************/
public static Integer id(String value) throws QBadRequestException
{
try
{
return (Integer.valueOf(value));
}
catch(Exception e)
{
throw new QBadRequestException("Saved analysis id must be an integer.");
}
}
/*******************************************************************************
** Accept a discriminated typed request; path table overrides only absent tableName.
*******************************************************************************/
@SuppressWarnings("unchecked")
public static SavedDefinition definition(Map<String, Object> body, String pathTable) throws QException
{
if(!(body.get("kind") instanceof String kind))
{
throw new QBadRequestException("An analysis kind is required.");
}
Map<String, Object> request = new LinkedHashMap<>(body);
request.remove("kind");
request.remove("format");
if(body.containsKey("request"))
{
if(!(body.get("request") instanceof Map<?, ?> map))
{
throw new QBadRequestException("request must be a typed analysis object.");
}
request = new LinkedHashMap<>((Map<String, Object>) map);
}
Object table = pathTable == null ? body.getOrDefault("tableName", request.get("tableName")) : pathTable;
if(!(table instanceof String name))
{
throw new QBadRequestException("tableName is required.");
}
return (SavedDefinition.parse(kind, name, request));
}
/*******************************************************************************
** Run the visible saved definition under the viewer's current source permissions.
*******************************************************************************/
public static class Run extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** Publish assistant-oriented semantics and field-reduction behavior.
*******************************************************************************/
public Run()
{
super("/saved/{id}/run", HttpMethod.POST, "Run a saved analysis",
"Re-run a visible saved analysis as the signed-in viewer. Requires saved READ and source READ/Analyze. Drops unavailable fields with a warning and returns the result typed by kind; inaccessible required axes or predicates produce no result.", null, "SavedResult");
}
/*******************************************************************************
** Core locks determine visibility, not an endpoint-specific owner check.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (DataAnalysisRuntime.saved().run(id(input.getPathParam("id"))));
}
}
/*******************************************************************************
** Visible saved metadata with this viewer's personal stars.
*******************************************************************************/
public static class ListSaved extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** A discoverable list rather than exposing the owner's cached result JSON.
*******************************************************************************/
public ListSaved()
{
super("/saved", HttpMethod.GET, "List visible saved analyses",
"List core-visible saved analyses with name, kind, source table label, owner, shared state, this viewer's starred flag and last update.", null, "SavedList");
}
/*******************************************************************************
** The list and star lookup both use enforced core queries.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (Map.of("analyses", DataAnalysisRuntime.saved().list()));
}
}
/*******************************************************************************
** Idempotent star PUT for the signed-in user.
*******************************************************************************/
public static class Star extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** No user-id body is accepted; stars always belong to the caller.
*******************************************************************************/
public Star()
{
super("/saved/{id}/star", HttpMethod.PUT, "Star a saved analysis",
"Star a visible saved analysis for the signed-in user. Idempotent; uses standard saved insert permission and the personal star record lock.", null, "Star");
}
/*******************************************************************************
** Persist through core table CRUD.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (Map.of("starred", DataAnalysisRuntime.saved().star(id(input.getPathParam("id")), true)));
}
}
/*******************************************************************************
** Idempotent star DELETE for the signed-in user.
*******************************************************************************/
public static class Unstar extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** Removal never targets another user's star.
*******************************************************************************/
public Unstar()
{
super("/saved/{id}/star", HttpMethod.DELETE, "Unstar a saved analysis",
"Remove the signed-in user's star on a visible saved analysis. Idempotent; uses standard saved delete permission and owner locks.", null, "Star");
}
/*******************************************************************************
** Persist through core table CRUD.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (Map.of("starred", DataAnalysisRuntime.saved().star(id(input.getPathParam("id")), false)));
}
}
/*******************************************************************************
** Actual backend aggregate SQL, captured only during this request.
*******************************************************************************/
public static class Sql extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** The assistant gets a reading aid, never an alternative execution endpoint.
*******************************************************************************/
public Sql()
{
super("/tables/{table}/sql", HttpMethod.POST, "Read an analysis aggregate SQL statement",
"Execute a typed analysis as the viewer and capture the main RDBMS aggregate SQL from core QueryStat. Requires installed SQL Workbench and its access permission. Non-RDBMS backends report unavailable; SQL is for reading and record security remains enforced at runtime.", "SavedRequest", "Sql");
}
/*******************************************************************************
** Requests pass through the same parser and analysis authorization.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (SqlHandoff.execute(definition(input.getBody(), input.getPathParam("table")), DataAnalysisRuntime.service()));
}
}
}
@@ -0,0 +1,99 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.api;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Versioned saved envelopes reuse all seven existing typed request/result schemas.
*******************************************************************************/
public final class SavedSchemas
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SavedSchemas()
{
}
/*******************************************************************************
** JSON Schema union supported by the standard OpenAPI serializer.
*******************************************************************************/
public static class RequestUnion extends Schema
{
/*******************************************************************************
** All supported typed analysis requests, selected by the sibling kind.
*******************************************************************************/
public List<Schema> getAnyOf()
{
return (List.of("ColumnProfile", "TableProfile", "Pivot", "Compare", "Correlation", "Crosstab", "Shift").stream()
.map(name -> ref(name + "Request")).toList());
}
}
/*******************************************************************************
** Every new endpoint's request and response is discoverable by clients.
*******************************************************************************/
public static Map<String, Schema> components()
{
Map<String, Schema> result = new LinkedHashMap<>();
Map<String, Schema> fields = new LinkedHashMap<>();
fields.put("kind", new Schema().withType(Type.STRING).withEnumValues(List.of("COLUMN_PROFILE", "TABLE_PROFILE", "PIVOT", "COMPARE", "CORRELATION", "CROSSTAB", "SHIFT")));
fields.put("tableName", new Schema().withType(Type.STRING).withDescription("Source QQQ table name; required for ad hoc export, supplied by the path for SQL."));
fields.put("request", new RequestUnion().withDescription("Typed request matching kind. Field names, filters and dimensions use the existing analysis request contracts."));
fields.put("format", new Schema().withType(Type.STRING).withEnumValues(List.of("csv", "xlsx")).withDescription("Required for export; ignored for SQL."));
result.put("DataAnalysisSavedRequestV1", new DataAnalysisSchemas.RequiredSchema(List.of("kind", "request")).withType(Type.OBJECT).withProperties(fields));
Map<String, Schema> response = new LinkedHashMap<>();
response.put("kind", fields.get("kind"));
response.put("warnings", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)));
new java.util.TreeMap<>(Map.of("columnProfile", "ColumnProfile", "tableProfile", "TableProfile", "pivot", "Pivot", "compare", "Compare",
"correlation", "Correlation", "crosstab", "Crosstab", "shift", "Shift")).forEach((key, value) -> response.put(key, ref(value).withNullable(true)));
result.put("DataAnalysisSavedResultV1", new Schema().withType(Type.OBJECT).withProperties(response));
Map<String, Schema> entry = new LinkedHashMap<>();
entry.put("id", new Schema().withType(Type.INTEGER));
for(String name : List.of("name", "kind", "tableName", "tableLabel", "owner", "updated"))
{
entry.put(name, new Schema().withType(Type.STRING));
}
entry.put("updated", new Schema().withType(Type.STRING).withFormat("date-time").withNullable(true));
entry.put("shared", new Schema().withType(Type.BOOLEAN));
entry.put("starred", new Schema().withType(Type.BOOLEAN));
result.put("DataAnalysisSavedEntryV1", new Schema().withType(Type.OBJECT).withProperties(entry));
result.put("DataAnalysisSavedListV1", new Schema().withType(Type.OBJECT).withProperties(new java.util.TreeMap<>(Map.of("analyses", new Schema().withType(Type.ARRAY).withItems(ref("SavedEntry"))))));
result.put("DataAnalysisStarV1", new Schema().withType(Type.OBJECT).withProperties(new java.util.TreeMap<>(Map.of("starred", new Schema().withType(Type.BOOLEAN)))));
result.put("DataAnalysisSqlV1", new Schema().withType(Type.OBJECT).withProperties(new java.util.TreeMap<>(Map.of("available", new Schema().withType(Type.BOOLEAN),
"sql", new Schema().withType(Type.STRING).withNullable(true), "note", new Schema().withType(Type.STRING)))));
result.put("DataAnalysisFileV1", new Schema().withType(Type.STRING).withFormat("binary"));
return (result);
}
/*******************************************************************************
** Reuse the extension's stable schema prefix.
*******************************************************************************/
private static Schema ref(String name)
{
return (new Schema().withRef("#/components/schemas/DataAnalysis" + name + "V1"));
}
}
@@ -20,35 +20,35 @@
package com.kingsrook.qbits.dataanalysis.app;
import java.time.Instant;
import java.util.Arrays;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.engine.AnalysisFormatting;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
@@ -58,35 +58,50 @@ import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Analyses app home: real counts under the current user's permissions and locks.
** Standard QQQ statistics and narrow table widgets, all queried as the viewer.
*******************************************************************************/
public class DataAnalysisHome extends AbstractWidgetRenderer
{
public static final String SETUP = "dataAnalysisSetup";
public static final String HOME_WIDGET = "dataAnalysisHome";
public static final String STARRED = "dataAnalysisStarred";
public static final String FIRING = "dataAnalysisFiring";
public static final String RECENT = "dataAnalysisRecent";
/*******************************************************************************
** The home itself has no data privilege; each section checks its table READ.
** The existing home name now supplies standard multi-statistics data.
*******************************************************************************/
public static QWidgetMetaData widget()
{
return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(DataAnalysisHome.class)).withGridColumns(12).withIsCard(false)
return (widget(HOME_WIDGET, "Analysis overview", WidgetType.MULTI_STATISTICS));
}
/*******************************************************************************
** Full-width tables have three or four columns and honest empty states.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(widget(SETUP, "Setup", WidgetType.TABLE), widget(), widget(STARRED, "Starred analyses", WidgetType.TABLE), widget(FIRING, "Firing watches", WidgetType.TABLE), widget(RECENT, "My recent runs", WidgetType.TABLE)));
}
/*******************************************************************************
** The widget itself grants no privilege; each data section checks table READ.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType()).withGridColumns(12).withIsCard(true)
.withShowReloadButton(true).withCodeReference(new QCodeReference(DataAnalysisHome.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)));
}
/*******************************************************************************
** Only main tables appear in navigation; schedules and stars are record children.
** Main tables remain navigation; schedules and stars remain child lists.
*******************************************************************************/
public static QAppMetaData app(DataAnalysisQBitConfig config, List<QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel())
.withIcon(new QIcon("insights")).withWidgets(List.of(HOME_WIDGET))
.withIcon(new QIcon("insights")).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList())
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withSection(new QAppSection().withName("analyses").withLabel("Analyses").withIcon(new QIcon("insights")).withTables(DataAnalysisTables.NAV_TABLES));
app.setParentAppName(config.getParentAppName());
@@ -100,194 +115,206 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
/*******************************************************************************
** RawHTML is carried as a JSON string by the HTML widget, not a text/html response.
** Entities keep static separators independent of any host charset handling.
** Dispatch data by registered metadata name, never by an arbitrary table input.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, render(Instant.now()))));
return (new RenderWidgetOutput(render(input.getWidgetMetaData().getName(), Instant.now())));
}
/*******************************************************************************
** Counts and table rows share core-enforced permissions and record locks.
*******************************************************************************/
public static QWidgetData render(String name, Instant now) throws QException
{
if(SETUP.equals(name))
{
return (setup());
}
if(HOME_WIDGET.equals(name))
{
return (statistics(now));
}
if(STARRED.equals(name))
{
TableData data = table("Starred analyses", DataAnalysisTables.SAVED, "No starred analyses yet. Star a saved analysis to see it here.", "Name", "Table", "Kind");
if(canRead(DataAnalysisTables.SAVED))
{
for(var entry : DataAnalysisRuntime.saved().list().stream().filter(SavedAnalyses.Entry::starred).toList())
{
data.getRows().add(values(link(DataAnalysisTables.SAVED, entry.id(), entry.name()), entry.tableLabel(), entry.kind()));
}
}
return (data);
}
String source = FIRING.equals(name) ? DataAnalysisTables.WATCH : DataAnalysisTables.RUN;
TableData data = FIRING.equals(name)
? table("Firing watches", source, "No firing watches.", "Watch", "Table", "Last evaluated", "Message")
: table("My recent runs", source, "No analysis runs yet.", "Analysis", "Table", "Status", "Started");
if(!canRead(source))
{
return (data);
}
QQueryFilter filter = FIRING.equals(name) ? equal("state", "FIRING")
: equal("user_id", SavedAnalyses.user()).withCriteria("started_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
filter.withOrderBy(new QFilterOrderBy(FIRING.equals(name) ? "last_evaluated_at" : "started_at", false)).withLimit(10);
for(var row : SavedAnalyses.query(source, filter))
{
String tableName = row.getValueString("table_name");
var metadata = QContext.getQInstance().getTable(tableName);
String label = metadata == null ? Objects.toString(tableName, "Saved analysis") : metadata.getLabel();
data.getRows().add(FIRING.equals(name)
? values(link(source, row.getValue("id"), row.getValueString("detector")), label, row.getValue("last_evaluated_at"), row.getValue("last_message"))
: values(link(source, row.getValue("id"), row.getValueString("operation")), label, row.getValue("status"), row.getValue("started_at")));
}
return (data);
}
/*******************************************************************************
** Counts are evaluated at one instant; no system session or synthetic numbers.
** The Setup card: each settings or content table, its state and the next step.
*******************************************************************************/
public static String render(Instant now) throws QException
public static TableData setup() throws QException
{
StringBuilder html = new StringBuilder("<div class=\"data-analysis-home\" style=\"display:grid;gap:1rem;min-width:0;overflow-wrap:anywhere\">");
String userId = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getIdReference();
html.append("<section><h3>Saved analyses</h3>");
if(userId == null)
TableData data = new TableData("Setup", List.of(new TableData.Column("html", "Part", "c0", null, "left"), new TableData.Column("text", "State", "c1", null, "left"),
new TableData.Column("text", "What it means", "c2", null, "left"), new TableData.Column("text", "Next step", "c3", null, "left")), new ArrayList<>())
.withNoRowsFoundHTML("Nothing to set up.").withRowsPerPage(10);
for(DataAnalysisSetupStatus.Item item : DataAnalysisSetupStatus.load())
{
html.append("<p>Sign in to see your saved analyses and stars.</p>");
}
else if(canRead(DataAnalysisTables.SAVED))
{
int mine = count(DataAnalysisTables.SAVED, equal("owner_user_id", userId));
int shared = count(DataAnalysisTables.SAVED, equal("shared", true));
html.append("<p>Mine: ").append(AnalysisFormatting.number(mine, 0)).append(" &middot; Shared (visible to you): ").append(AnalysisFormatting.number(shared, 0)).append("</p>");
if(canRead(DataAnalysisTables.SAVED_STAR))
String path = QContext.getQInstance().getTablePath(item.tableName());
String part = "<a href=\"" + escape(path) + "\">" + escape(item.label()) + "</a>";
String state = switch(item.state())
{
CountInput stars = new CountInput(DataAnalysisTables.SAVED).withIncludeDistinctCount(true)
.withQueryJoin(new QueryJoin(QContext.getQInstance().getJoin(DataAnalysisTables.joinName(DataAnalysisTables.SAVED_STAR))).withType(QueryJoin.Type.INNER))
.withFilter(equal(DataAnalysisTables.SAVED_STAR + ".user_id", userId));
stars.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
int starred = new CountAction().execute(stars).getDistinctCount();
html.append("<p>Starred by me: ").append(AnalysisFormatting.number(starred, 0)).append("</p>");
}
if(mine == 0 && shared == 0)
{
html.append("<p>No saved analyses yet. Open any table and choose Analyze on a column header to explore its data.</p>");
}
link(html, DataAnalysisTables.SAVED);
case OK -> "Set up";
case DEFAULT -> "Using defaults";
case OPTIONAL -> "Optional";
case UNAVAILABLE -> "Unavailable";
case UNKNOWN -> "Needs attention";
};
data.getRows().add(values(part, state, item.detail(), item.nextStep()));
}
else
return (data);
}
/*******************************************************************************
** One standard group per subject, with real counts or an unavailable explanation.
*******************************************************************************/
public static MultiStatisticsData statistics(Instant now) throws QException
{
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
var saved = group("Saved analyses", DataAnalysisTables.SAVED);
if(canRead(DataAnalysisTables.SAVED))
{
html.append("<p>Saved analyses are unavailable: you need data_analysis.saved.read.</p>");
List<SavedAnalyses.Entry> entries = DataAnalysisRuntime.saved().list();
String user = SavedAnalyses.user();
saved.withStatisticList(List.of(stat("Mine", (int) entries.stream().filter(e -> e.owner().equals(user)).count(), DataAnalysisTables.SAVED),
stat("Shared", (int) entries.stream().filter(e -> Boolean.TRUE.equals(e.shared())).count(), DataAnalysisTables.SAVED),
stat("Starred", (int) entries.stream().filter(SavedAnalyses.Entry::starred).count(), DataAnalysisTables.SAVED)));
}
html.append("</section><section><h3>Watches</h3>");
groups.add(saved);
var watches = group("Watches", DataAnalysisTables.WATCH);
if(canRead(DataAnalysisTables.WATCH))
{
int total = count(DataAnalysisTables.WATCH, new QQueryFilter());
int enabled = count(DataAnalysisTables.WATCH, equal("enabled", true));
int firing = count(DataAnalysisTables.WATCH, equal("state", "FIRING"));
html.append("<p>Total: ").append(AnalysisFormatting.number(total, 0)).append(" &middot; Enabled: ").append(AnalysisFormatting.number(enabled, 0)).append(" &middot; Firing: ").append(AnalysisFormatting.number(firing, 0)).append("</p>");
if(total == 0)
{
html.append("<p>No watches yet.</p>");
}
QueryInput watches = new QueryInput(DataAnalysisTables.WATCH).withShouldFetchHeavyFields(true)
.withFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("state", true)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(5));
watches.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<QRecord> recent = new QueryAction().execute(watches).getRecords();
if(!recent.isEmpty())
{
html.append("<ul>");
for(QRecord watch : recent)
{
String path = QContext.getQInstance().getTablePath(DataAnalysisTables.WATCH) + "/" + watch.getValueInteger("id");
html.append("<li><a href=\"").append(escape(path)).append("\">")
.append(escape(words(watch.getValueString("state")))).append(" &middot; ")
.append(escape(words(watch.getValueString("detector")))).append("</a>");
if(watch.getValueString("last_message") != null)
{
html.append(" — ").append(escape(watch.getValueString("last_message")));
}
html.append("</li>");
}
html.append("</ul>");
}
link(html, DataAnalysisTables.WATCH);
watches.withStatisticList(List.of(stat("Enabled", count(DataAnalysisTables.WATCH, equal("enabled", true)), DataAnalysisTables.WATCH),
stat("Firing", count(DataAnalysisTables.WATCH, equal("state", "FIRING")), DataAnalysisTables.WATCH)));
}
else
groups.add(watches);
var runs = group("My runs", DataAnalysisTables.RUN);
if(canRead(DataAnalysisTables.RUN))
{
html.append("<p>Watches are unavailable: you need data_analysis.watch.read.</p>");
runs.withStatisticList(List.of(stat("Last 7 days", count(DataAnalysisTables.RUN, equal("user_id", SavedAnalyses.user())
.withCriteria("started_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
.withCriteria("started_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)), DataAnalysisTables.RUN)));
}
html.append("</section><section><h3>Recent analysis runs</h3>");
if(userId == null)
{
html.append("<p>Sign in to see your recent analysis runs.</p>");
}
else if(canRead(DataAnalysisTables.RUN))
{
QueryInput query = new QueryInput(DataAnalysisTables.RUN).withFilter(equal("user_id", userId)
.withCriteria(new QFilterCriteria("started_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))
.withOrderBy(new QFilterOrderBy("started_at", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(5));
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
List<QRecord> runs = new QueryAction().execute(query).getRecords();
if(runs.isEmpty())
{
html.append("<p>No analysis runs yet.</p>");
}
else
{
html.append("<ul class=\"data-analysis-runs\">");
for(QRecord run : runs)
{
QTableMetaData table = QContext.getQInstance().getTable(run.getValueString("table_name"));
String tableLabel = table == null ? run.getValueString("table_name") : table.getLabel();
String path = QContext.getQInstance().getTablePath(DataAnalysisTables.RUN) + "/" + run.getValueInteger("id");
html.append("<li><a href=\"").append(escape(path)).append("\">").append(escape(tableLabel)).append(" &middot; ")
.append(escape(words(run.getValueString("operation")))).append("</a><dl class=\"data-analysis-run-details\"><dt>Status</dt><dd>")
.append(escape(words(run.getValueString("status")))).append("</dd><dt>Finished</dt><dd>")
.append(run.getValue("finished_at") == null ? "Not finished" : escape(AnalysisFormatting.instant(run.getValueInstant("finished_at"))))
.append("</dd></dl></li>");
}
html.append("</ul>");
}
link(html, DataAnalysisTables.RUN);
}
else
{
html.append("<p>Run history is unavailable: you need data_analysis.run.read.</p>");
}
return (html.append("</section></div>").toString());
groups.add(runs);
return (new MultiStatisticsData("Analyses", groups));
}
/*******************************************************************************
** Explicit permission checks avoid presenting denied sections as empty data.
** Empty/denied data are different messages, not synthetic zeroes.
*******************************************************************************/
private static boolean canRead(String table)
private static MultiStatisticsData.StatisticsGroupData group(String label, String table)
{
return (PermissionsHelper.hasTablePermission(new CountInput(table), table, TablePermissionSubType.READ));
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of())
.withSubheader(canRead(table) ? "Visible to you" : "Unavailable: READ permission required."));
}
/*******************************************************************************
** Equality filter for one count.
** Link every count to its actual table path.
*******************************************************************************/
private static QQueryFilter equal(String field, java.io.Serializable value)
private static MultiStatisticsData.StatisticsGroupData.Statistic stat(String label, int count, String table) throws QException
{
return (new QQueryFilter().withCriteria(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
return (new MultiStatisticsData.StatisticsGroupData.Statistic(label, count, QContext.getQInstance().getTablePath(table)));
}
/*******************************************************************************
** Stored operation and status keys are displayed as ordinary words.
** Standard table cells support escaped record links; other columns are plain text.
*******************************************************************************/
private static String words(String value)
private static TableData table(String label, String table, String empty, String... headers) throws QException
{
return (value == null ? "Unknown" : Arrays.stream(value.toLowerCase(Locale.ROOT).split("_"))
.filter(part -> !part.isEmpty()).map(part -> Character.toUpperCase(part.charAt(0)) + part.substring(1)).collect(Collectors.joining(" ")));
List<TableData.Column> columns = new ArrayList<>();
for(int i = 0; i < headers.length; i++)
{
columns.add(new TableData.Column(i == 0 ? "html" : "text", headers[i], "c" + i, null, "left"));
}
return (new TableData(label, columns, new ArrayList<>()).withNoRowsFoundHTML(canRead(table) ? empty : "Unavailable: READ permission required.")
.withLinkText("View all").withLinkURL(QContext.getQInstance().getTablePath(table)).withRowsPerPage(10));
}
/*******************************************************************************
** Preserve real values while avoiding null map entries and executable HTML.
*******************************************************************************/
private static Map<String, Object> values(Object... values)
{
Map<String, Object> row = new LinkedHashMap<>();
for(int i = 0; i < values.length; i++)
{
row.put("c" + i, Objects.toString(values[i], ""));
}
return (row);
}
/*******************************************************************************
** Metadata and persisted values must never become executable widget markup.
** Standard table HTML cell contains only an escaped link and escaped text.
*******************************************************************************/
private static String link(String table, Object id, String label) throws QException
{
return ("<a href=\"" + escape(QContext.getQInstance().getTablePath(table) + "/" + id) + "\">" + escape(label) + "</a>");
}
/*******************************************************************************
** Metadata labels and stored names are never trusted markup.
*******************************************************************************/
private static String escape(String value)
{
return (value == null ? "Unknown" : value.replace("&", "&amp;").replace("\"", "&quot;").replace("<", "&lt;").replace(">", "&gt;").replace("'", "&#39;"));
return (Objects.toString(value, "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
/*******************************************************************************
** Explicit checks distinguish denied sections from empty sections.
*******************************************************************************/
static boolean canRead(String table)
{
CountInput input = new CountInput(table);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (PermissionsHelper.hasTablePermission(input, table, TablePermissionSubType.READ));
}
/*******************************************************************************
** CountAction enforces table permission and record locks using QContext's session.
** Simple filter; record security is applied exclusively by core.
*******************************************************************************/
private static int count(String table, QQueryFilter filter) throws QException
private static QQueryFilter equal(String field, java.io.Serializable value)
{
return (DrillFilters.condition(field, QCriteriaOperator.EQUALS, value));
}
/*******************************************************************************
** Counts use the viewer's session and explicitly enforce permissions.
*******************************************************************************/
static int count(String table, QQueryFilter filter) throws QException
{
CountInput input = new CountInput(table).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new CountAction().execute(input).getCount());
}
/*******************************************************************************
** Use the instance's actual app path, including custom names and parent apps.
*******************************************************************************/
private static void link(StringBuilder html, String table) throws QException
{
String path = QContext.getQInstance().getTablePath(table);
html.append("<a href=\"").append(escape(path))
.append("\">View all</a>");
}
}
@@ -0,0 +1,173 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** The Setup card on the Analyses home: each configuration or content table,
** whether it is set up, and the next step. Reads settings through the same
** resolver the analysis runtime uses (code defaults, then rows), so the card
** never reports something the runtime would not do. Counts run as the viewer.
*******************************************************************************/
public final class DataAnalysisSetupStatus
{
/*******************************************************************************
** OK: set up. DEFAULT: working on built-in defaults (nothing required).
** OPTIONAL: a feature that stays off until someone uses it. UNAVAILABLE: the
** viewer cannot read the table.
*******************************************************************************/
public enum State
{
OK, DEFAULT, OPTIONAL, UNAVAILABLE, UNKNOWN
}
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisSetupStatus()
{
}
/*******************************************************************************
** The card's lines, in the order a person would set things up.
*******************************************************************************/
public static List<Item> load() throws QException
{
DataAnalysisQBitConfig config = DataAnalysisRuntime.service().getConfigResolver().getDefaults();
List<Item> items = new ArrayList<>();
///////////////////////
// analysis settings //
///////////////////////
List<String> analyzable = TableConfigDefaultsProcess.analyzableTables(QContext.getQInstance(), config);
if(!DataAnalysisHome.canRead(DataAnalysisTables.TABLE_CONFIG))
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
}
else
{
int rows = DataAnalysisHome.count(DataAnalysisTables.TABLE_CONFIG, new QQueryFilter());
String defaults = "max rows " + config.getDefaultMaxRows() + ", sample " + config.getDefaultSampleRows() + " above " + config.getDefaultSampleThresholdRows()
+ ", smallest group " + config.getDefaultMinGroupSize() + ", results reused " + config.getDefaultCacheTtlMinutes() + " min";
if(rows == 0)
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.DEFAULT,
"All " + analyzable.size() + " analyzable tables are on, with the defaults (" + defaults + ").",
"Run Create rows for analyzable tables to see and edit them, or add a row to change one table."));
}
else
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, rows >= analyzable.size() ? State.OK : State.DEFAULT,
rows + " settings row" + (rows == 1 ? "" : "s") + " for " + analyzable.size() + " analyzable tables; tables without a row use the defaults (" + defaults + ").",
rows >= analyzable.size() ? null : "Run Create rows for analyzable tables to add rows for the rest."));
}
}
/////////////////////////////////////////////////////////////////////
// result cache: the resolved value, exactly what caching will use //
/////////////////////////////////////////////////////////////////////
items.add(cacheItem(analyzable, config));
///////////////////////////////////////////////////
// saved analyses, watches, schedules: optional //
///////////////////////////////////////////////////
items.add(optional("Saved analyses", DataAnalysisTables.SAVED, "No saved analyses yet.", "Save an analysis from the Analyze screen, then star or share it."));
String scheduler = config.getSchedulerName() == null ? " No scheduler is configured, so watches only run with Evaluate now." : "";
items.add(optional("Watches", DataAnalysisTables.WATCH, "No watches yet." + scheduler, "Add a watch on a saved analysis to be alerted to spikes, drops and new categories."));
String schedules = config.getSchedulerName() == null ? " No scheduler is configured, so schedules do not run." : "";
items.add(optional("Schedules", DataAnalysisTables.SCHEDULE, "No schedules yet." + schedules, "Add a schedule on a saved analysis to send it by email."));
return (items);
}
/*******************************************************************************
** One optional feature: set up once it has rows.
*******************************************************************************/
private static Item optional(String label, String table, String emptyDetail, String nextStep) throws QException
{
if(!DataAnalysisHome.canRead(table))
{
return (new Item(label, table, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
}
int rows = DataAnalysisHome.count(table, new QQueryFilter());
return (rows == 0 ? new Item(label, table, State.OPTIONAL, emptyDetail, nextStep) : new Item(label, table, State.OK, rows + " in use.", null));
}
/*******************************************************************************
** The Result cache line, from the minutes every analyzable table actually
** resolves to (settings row, then table defaults, then the global default).
*******************************************************************************/
private static Item cacheItem(List<String> analyzable, DataAnalysisQBitConfig config)
{
java.util.TreeMap<Integer, Integer> tablesPerMinutes = new java.util.TreeMap<>();
for(String table : analyzable)
{
try
{
tablesPerMinutes.merge(DataAnalysisRuntime.service().getConfigResolver().resolve(table).getCacheTtlMinutes(), 1, Integer::sum);
}
catch(QException e)
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.UNKNOWN, "The cache settings of " + table + " could not be read: " + e.getMessage(),
"Fix the settings row for that table (a negative limit is invalid)."));
}
}
if(tablesPerMinutes.isEmpty() || (tablesPerMinutes.size() == 1 && tablesPerMinutes.firstKey() == 0))
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.DEFAULT, "Off: every exact run computes fresh.", "Set cache minutes on Analysis Settings to reuse results."));
}
if(tablesPerMinutes.size() == 1)
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused for " + tablesPerMinutes.firstKey() + " minutes.", null));
}
String parts = tablesPerMinutes.entrySet().stream().map(e -> e.getValue() + (e.getValue() == 1 ? " table" : " tables") + (e.getKey() == 0 ? ": off" : ": " + e.getKey() + " min"))
.collect(java.util.stream.Collectors.joining(", "));
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused per table (default " + config.getDefaultCacheTtlMinutes() + " min): " + parts + ".", null));
}
}
@@ -0,0 +1,157 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.app;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.result.Chart;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartSubheaderData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
/*******************************************************************************
** Saved analysis rendered through Next's existing standard chart widget.
*******************************************************************************/
public class SavedAnalysisWidget extends AbstractWidgetRenderer
{
public static final String NAME = "DATA_ANALYSIS_SAVED";
/*******************************************************************************
** Hosts clone this metadata and set savedId to place an analysis on a dashboard.
*******************************************************************************/
public static QWidgetMetaData widget()
{
return (new QWidgetMetaData().withName(NAME).withLabel("Saved analysis").withType(WidgetType.BAR_CHART.getType())
.withGridColumns(12).withIsCard(true).withCodeReference(new QCodeReference(SavedAnalysisWidget.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)));
}
/*******************************************************************************
** Values come from a fresh viewer run; no stored owner result is ever reused.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
var defaults = ((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues();
Object value = defaults == null ? null : defaults.get("savedId");
if(value == null)
{
return (new RenderWidgetOutput(new ChartData("Saved analysis", "Choose a saved analysis for this widget.", "Analysis", List.of(), List.of())));
}
Integer id;
try
{
id = Integer.valueOf(value.toString());
}
catch(NumberFormatException e)
{
throw new QBadRequestException("savedId must be an integer.");
}
var saved = WatchRecords.get(DataAnalysisTables.SAVED, id);
var result = DataAnalysisRuntime.saved().run(id);
List<Metric> metrics = AnalysisExport.headlines(result);
Chart chart = null;
if(result.columnProfile() != null)
{
chart = result.columnProfile().getChart();
}
else if(result.tableProfile() != null)
{
chart = result.tableProfile().getHeatmap();
}
else if(result.correlation() != null)
{
chart = result.correlation().getChart();
}
else if(result.shift() != null)
{
chart = result.shift().getChart();
}
else if(result.compare() != null && !result.compare().getFields().isEmpty())
{
chart = result.compare().getFields().getFirst().getChart();
}
String description = metrics.stream().filter(Objects::nonNull).map(m -> m.getLabel() + ": " + AnalysisExport.value(m))
.collect(java.util.stream.Collectors.joining("; "));
if(!result.warnings().isEmpty())
{
description += " " + String.join("; ", result.warnings());
}
ChartData data = new ChartData().withTitle(saved.getValueString("name")).withDescription(description).withHeight(280);
String path = QContext.getQInstance().getTablePath(DataAnalysisTables.SAVED) + "/" + id;
Number mainNumber = metrics.stream().filter(m -> !Boolean.TRUE.equals(m.getSuppressed()) && m.getValue() instanceof Number)
.map(m -> (Number) m.getValue()).findFirst().orElse(null);
data.withChartSubheaderData(new ChartSubheaderData().withMainNumber(mainNumber).withMainNumberUrl(path));
List<String> labels = new ArrayList<>();
List<ChartData.Data.Dataset> datasets = new ArrayList<>();
if(chart != null && !chart.getSeries().isEmpty())
{
chart.getSeries().getFirst().getPoints().forEach(p -> labels.add(Objects.toString(p.getLabel(), Objects.toString(p.getX(), ""))));
for(var series : chart.getSeries())
{
datasets.add(new ChartData.Data.Dataset().withLabel(series.getName())
.withData(series.getPoints().stream().map(p -> (Number) p.getY()).toList()));
}
}
else if(result.pivot() != null)
{
List<Number> numbers = new ArrayList<>();
for(var cell : result.pivot().getCells())
{
labels.add(java.util.stream.Stream.concat(result.pivot().getRowHeaders().get(cell.getRowIndex()).stream(),
result.pivot().getColumnHeaders().get(cell.getColumnIndex()).stream()).map(p -> p.getLabel()).collect(java.util.stream.Collectors.joining(" / ")));
Metric metric = cell.getValues().getFirst();
numbers.add(!Boolean.TRUE.equals(metric.getSuppressed()) && metric.getValue() instanceof Number n ? n : null);
}
datasets.add(new ChartData.Data.Dataset().withLabel(result.pivot().getMeasures().getFirst().getLabel()).withData(numbers));
}
else if(result.crosstab() != null)
{
List<Number> numbers = new ArrayList<>();
for(var cell : result.crosstab().getCells())
{
labels.add(result.crosstab().getRows().get(cell.getRowIndex()).getLabel() + " / " + result.crosstab().getColumns().get(cell.getColumnIndex()).getLabel());
Metric metric = cell.getCount();
numbers.add(!Boolean.TRUE.equals(metric.getSuppressed()) && metric.getValue() instanceof Number n ? n : null);
}
datasets.add(new ChartData.Data.Dataset().withLabel("Count").withData(numbers));
}
data.withChartData(new ChartData.Data().withLabels(labels).withUrls(labels.stream().map(label -> path).toList()).withDatasets(datasets));
data.withFooterHTML("<a href=\"" + path.replace("&", "&amp;").replace("\"", "&quot;") + "\">View saved analysis</a>");
return (new RenderWidgetOutput(data));
}
}
@@ -65,6 +65,16 @@ public class EffectiveConfigResolver
/*******************************************************************************
** The code configuration this resolver starts from.
*******************************************************************************/
public DataAnalysisQBitConfig getDefaults()
{
return (defaults);
}
/*******************************************************************************
** Return a detached snapshot so callers cannot change memoized policy.
*******************************************************************************/
@@ -41,7 +41,6 @@ import com.kingsrook.qbits.dataanalysis.result.PivotRequest;
import com.kingsrook.qbits.dataanalysis.result.PivotResult;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -617,7 +616,7 @@ public class PivotExplorer
groups.forEach(input::withGroupBy);
new LinkedHashSet<>(aggregates).forEach(input::withAggregate);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
var output = new AggregateAction().execute(input);
var output = com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.aggregate(input);
partial |= output.getTruncated();
return (limit == null ? output.getResults() : output.getResults().stream().sorted(java.util.Comparator.comparingLong(this::count).reversed()
.thenComparing(r -> String.valueOf(r.getGroupByValues().values()))).limit(limit).toList());
@@ -33,7 +33,6 @@ import com.kingsrook.qbits.dataanalysis.result.Significance;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qbits.dataanalysis.stats.Statistics;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
@@ -79,7 +78,7 @@ public class RelationshipSupport extends StandInAnalysisBackend
{
input.withFilter(DrillFilters.restoreTypes(table, input.getFilter())).withAllowStreamedFallback(true).withStreamedRowLimit(config.getMaxRows());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
var output = new AggregateAction().execute(input);
var output = com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.aggregate(input);
if(Boolean.TRUE.equals(output.getTruncated()))
{
throw new QBadRequestException("Analysis exceeds the streamed row limit; narrow the filter.");
@@ -38,7 +38,6 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -171,7 +170,7 @@ public class StandInAnalysisBackend implements AnalysisBackend
restored.setOrderBys(input.getFilter().getOrderBys());
input.setFilter(restored);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new AggregateAction().execute(input).getResults());
return (com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.aggregate(input).getResults());
}
@@ -0,0 +1,132 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.help;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-data-analysis.yaml (the QQQ help-bundle format) to the
** Analyses app and its data_analysis_* tables: what each table is for and
** how its rows get created, shown on the table screen and when it is empty.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class DataAnalysisHelp
{
public static final String RESOURCE = "/help/qbit-data-analysis.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = DataAnalysisHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-data-analysis".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every data_analysis_* table in the instance.
** A host's own help on a table is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance instance, DataAnalysisQBitConfig config) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : instance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = instance.getApp(config.getAppName());
JsonNode appEntry = entries.get("app:dataAnalysis;slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -59,6 +59,7 @@ public class DataAnalysisMetaDataProducer extends QBitComponentMetaDataProducer<
DataAnalysisTables.joins().forEach(output::add);
DataAnalysisTables.childListWidgets().forEach(output::add);
output.add(DataAnalysisTables.userKeyType());
output.add(com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess.process());
List<QTableMetaData> tables = DataAnalysisTables.tables(getQBitConfig());
for(QTableMetaData table : tables)
{
@@ -66,7 +67,8 @@ public class DataAnalysisMetaDataProducer extends QBitComponentMetaDataProducer<
.filter(qbit -> DataAnalysisQBitProducer.ARTIFACT_ID.equals(qbit.getArtifactId())).findFirst().orElseThrow().getName());
output.add(table);
}
output.add(DataAnalysisHome.widget());
DataAnalysisHome.widgets().forEach(output::add);
output.add(com.kingsrook.qbits.dataanalysis.app.SavedAnalysisWidget.widget());
output.add(DataAnalysisHome.app(getQBitConfig(), tables));
return (output);
}
@@ -0,0 +1,93 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.metadata;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.ScheduleActions;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Watch record actions and one hidden scheduled process, never app children.
*******************************************************************************/
public class ScheduleProcesses extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, DataAnalysisQBitConfig>
{
/*******************************************************************************
** Build after the watch table has been registered.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (200);
}
/*******************************************************************************
** Component scanning also encounters unconfigured producers.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null);
}
/*******************************************************************************
** Ordinary core actions provide selected records, Review, and Result screens.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance)
{
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
for(String name : List.of(ScheduleActions.SEND))
{
String label = "Send now";
QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(name).withLabel(label).withIcon(new QIcon("notifications_active"))
.withTableName(DataAnalysisTables.SCHEDULE).withSourceTable(DataAnalysisTables.SCHEDULE).withDestinationTable(DataAnalysisTables.SCHEDULE)
.withExtractStepClass(ExtractViaQueryStep.class).withTransformStepClass(ScheduleActions.Review.class).withLoadStepClass(ScheduleActions.Result.class)
.withSupportsFullValidation(true).withDoFullValidation(true).withMinInputRecords(1)
.withPreviewMessage("Review the selected schedules.").getProcessMetaData();
// Table READ/EDIT and owner locks are enforced by extraction and both steps.
process.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED));
output.add(process);
}
QProcessMetaData scheduled = new QProcessMetaData().withName(ScheduleActions.SCHEDULED).withLabel("Run analysis schedules").withIsHidden(true)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(ScheduleActions.SCHEDULED))
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ScheduleActions.Scheduled.class)));
if(getQBitConfig().getSchedulerName() != null)
{
scheduled.withSchedule(new QScheduleMetaData().withSchedulerName(getQBitConfig().getSchedulerName()).withRepeatSeconds(300).withInitialDelaySeconds(300));
}
output.add(scheduled);
return (output);
}
}
@@ -237,14 +237,21 @@ public final class DataAnalysisTables
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("data_analysis_kind").withIsRequired(true))
.withField(field("definition_json", QFieldType.TEXT).withIsRequired(true))
.withField(field("chart_kind", QFieldType.STRING).withPossibleValueSourceName("data_analysis_chart_kind"))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withField(field("shared", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("read_owner_user_id", QFieldType.STRING).withIsEditable(false).withIsHidden(true))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withRecordSecurityLock(ownerLock("owner_user_id")));
.withRecordSecurityLock(ownerLock("owner_user_id").withLockScope(RecordSecurityLock.LockScope.WRITE))
.withRecordSecurityLock(ownerLock("read_owner_user_id").withLockScope(RecordSecurityLock.LockScope.READ)
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.saved.SavedCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.saved.SavedCustomizer.class)));
tables.add(base(config, SAVED_STAR, "Stars", "star", "data_analysis.saved")
.withRecordLabelFormatAndFields("%s", "user_id")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved").withIsRequired(true))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withUniqueKey(new UniqueKey("saved_id", "user_id"))
.withRecordSecurityLock(ownerLock("user_id")));
tables.add(base(config, SCHEDULE, "Schedules", "schedule", "data_analysis.saved")
@@ -259,9 +266,13 @@ public final class DataAnalysisTables
.withField(field("recipients", QFieldType.TEXT))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("last_run_at", QFieldType.DATE_TIME))
.withField(field("last_status", QFieldType.STRING).withPossibleValueSourceName("data_analysis_run_status"))
.withField(field("last_status", QFieldType.TEXT).withIsEditable(false))
.withField(field("next_run_at", QFieldType.DATE_TIME))
.withRecordSecurityLock(ownerLock(SAVED + ".owner_user_id").withJoinNameChain(List.of(joinName(SCHEDULE)))));
.withRecordSecurityLock(ownerLock(SAVED + ".owner_user_id").withJoinNameChain(List.of(joinName(SCHEDULE))))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.schedule.ScheduleCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.schedule.ScheduleCustomizer.class)));
tables.add(base(config, WATCH, "Watches", "notifications_active", "data_analysis.watch")
.withRecordLabelFormatAndFields("%s", "detector")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved")
@@ -274,7 +285,7 @@ public final class DataAnalysisTables
.withField(field("date_field", QFieldType.STRING).withIsRequired(true))
.withField(field("bucket", QFieldType.STRING).withDefaultValue("DAY").withIsRequired(true).withPossibleValueSourceName("data_analysis_watch_bucket"))
.withField(field("severity", QFieldType.STRING).withDefaultValue("WARNING").withIsRequired(true).withPossibleValueSourceName("data_analysis_watch_severity"))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("last_evaluated_at", QFieldType.DATE_TIME).withIsEditable(false))
.withField(field("last_value", QFieldType.DECIMAL).withIsEditable(false))
@@ -300,12 +311,12 @@ public final class DataAnalysisTables
.withField(field("sampled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("result_json", QFieldType.TEXT))
.withField(field("error", QFieldType.TEXT))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withRecordSecurityLock(ownerLock("user_id")));
for(QTableMetaData table : tables)
{
List<String> fields = new ArrayList<>(table.getFields().keySet());
fields.removeAll(List.of("id", "createDate", "modifyDate"));
fields.removeAll(List.of("id", "createDate", "modifyDate", "read_owner_user_id"));
int headlineSize = Math.min(4, fields.size());
table.withSection(SectionFactory.defaultT1(fields.subList(0, headlineSize).toArray(String[]::new)));
if(fields.size() > headlineSize)
@@ -0,0 +1,84 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.model;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** On INSERT only, an owner/user field left empty gets the inserting user's id,
** so a UI (which does not know the user's id) can create a saved analysis,
** star or watch. Unlike core DynamicDefaultValueBehavior.USER_ID it never
** applies on UPDATE, so an administrator's partial edit cannot take ownership.
** Naming another owner is still refused by the table customizers and locks.
*******************************************************************************/
public enum InsertingUserBehavior implements FieldBehavior<InsertingUserBehavior>
{
INSERTING_USER,
NONE;
/*******************************************************************************
** No behavior unless asked for.
*******************************************************************************/
@Override
public InsertingUserBehavior getDefault()
{
return (NONE);
}
/*******************************************************************************
** Fill empty values with the session user's id on insert.
*******************************************************************************/
@Override
public void apply(ValueBehaviorApplier.Action action, List<QRecord> recordList, QInstance instance, QTableMetaData table, QFieldMetaData field)
{
if(this == NONE || action != ValueBehaviorApplier.Action.INSERT)
{
return;
}
String userId = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getIdReference();
if(!StringUtils.hasContent(userId))
{
return;
}
for(QRecord record : CollectionUtils.nonNullList(recordList))
{
if(!StringUtils.hasContent(record.getValueString(field.getName())))
{
record.setValue(field.getName(), userId);
}
}
}
}
@@ -0,0 +1,144 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.processes;
import java.time.Instant;
import java.util.ArrayList;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
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.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
/*******************************************************************************
** Standard streamed ETL Review and Result actions for scheduled analyses.
*******************************************************************************/
public final class ScheduleActions
{
public static final String SEND = "data_analysis_schedule_send";
public static final String SCHEDULED = "data_analysis_run_schedules";
/*******************************************************************************
** Steps only.
*******************************************************************************/
private ScheduleActions()
{
}
/*******************************************************************************
** Review has no delivery side effects.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Core extraction enforces record locks; verify the edit grant as well.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(!PermissionsHelper.hasTablePermission(new UpdateInput(DataAnalysisTables.SCHEDULE), DataAnalysisTables.SCHEDULE, TablePermissionSubType.EDIT))
{
throw new QPermissionDeniedException("Schedule editing is unavailable.");
}
for(QRecord record : input.getRecords())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Schedule " + record.getValueString("id") + " will be sent to authorized recipients."));
output.addRecord(record);
}
}
/*******************************************************************************
** Standard Review summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (result ? new ArrayList<>() : lines);
}
}
/*******************************************************************************
** Every selected record contributes its actual delivery status to Result.
*******************************************************************************/
public static class Result extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Reauthorize when executing after Review.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
try
{
String status = DataAnalysisRuntime.schedules().sendNow(record.getValue("id"), Instant.now());
lines.add(new ProcessSummaryLine(status.startsWith("Failed") ? Status.ERROR : Status.OK, null, status));
output.addRecord(record);
}
catch(QException e)
{
lines.add(new ProcessSummaryLine(Status.ERROR, null, e.getMessage()));
}
}
}
/*******************************************************************************
** Actual results across streamed pages.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
/*******************************************************************************
** Hidden scheduler every five minutes.
*******************************************************************************/
public static class Scheduled implements BackendStep
{
/*******************************************************************************
** Count actual due schedule attempts.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
output.addValue("count", DataAnalysisRuntime.schedules().runDue(Instant.now()));
}
}
}
@@ -0,0 +1,301 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Create rows for analyzable tables": a table action on Analysis Settings
** that writes one row per analyzable table that has none yet, holding the
** settings that already apply to it (code defaults for that table, then the
** global defaults). It changes no behavior on its own: it makes the defaults
** visible and editable. Existing rows are never touched. Standard streamed
** ETL: Review lists what will be added, Result what was added.
*******************************************************************************/
public final class TableConfigDefaultsProcess
{
public static final String NAME = "data_analysis_table_config_create_rows";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TableConfigDefaultsProcess()
{
}
/*******************************************************************************
** The process: a table-level action (no records need to be selected). Only
** people who may insert Analysis Settings see it; the steps enforce the table
** permissions again.
*******************************************************************************/
public static QProcessMetaData process()
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, DataAnalysisTables.TABLE_CONFIG);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, DataAnalysisTables.TABLE_CONFIG);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
"These rows will be added: one per analyzable table that has none, holding the settings that already apply. Existing rows are not changed.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
process.setName(NAME);
process.setLabel("Create rows for analyzable tables");
process.setIcon(new QIcon("playlist_add"));
process.setTableName(DataAnalysisTables.TABLE_CONFIG);
process.setMinInputRecords(0);
process.setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(DataAnalysisTables.TABLE_CONFIG, TablePermissionSubType.INSERT)));
return (process);
}
/*******************************************************************************
** The tables analysis may be used on: everything but the qbit's own tables,
** limited by the includeTables and excludeTables configuration. The same test
** the "analyze" table permission uses.
*******************************************************************************/
public static List<String> analyzableTables(QInstance instance, DataAnalysisQBitConfig config)
{
return (instance.getTables().values().stream().map(QTableMetaData::getName)
.filter(name -> !name.startsWith(DataAnalysisQBitConfig.TABLE_NAME_PREFIX))
.filter(name -> config.getIncludeTables().isEmpty() || config.getIncludeTables().contains(name))
.filter(name -> !config.getExcludeTables().contains(name)).sorted().toList());
}
/*******************************************************************************
** The rows that are missing: one per analyzable table without a row, with
** the effective settings (the same values EffectiveConfigResolver produces).
*******************************************************************************/
public static List<QRecord> missingRows(QInstance instance, DataAnalysisQBitConfig config) throws QException
{
Set<String> existing = existingTables();
DataAnalysisRuntime.service().getConfigResolver().invalidate();
List<QRecord> rows = new ArrayList<>();
for(String table : analyzableTables(instance, config))
{
if(!existing.contains(table))
{
EffectiveConfig c = DataAnalysisRuntime.service().getConfigResolver().resolve(table);
rows.add(new QRecord().withValue("table_name", table).withValue("enabled", c.getEnabled()).withValue("max_rows", c.getMaxRows())
.withValue("sample_threshold_rows", c.getSampleThresholdRows()).withValue("sample_rows", c.getSampleRows())
.withValue("min_group_size", c.getMinGroupSize()).withValue("cache_ttl_minutes", c.getCacheTtlMinutes())
.withValue("analyzable_fields", c.getAnalyzableFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getAnalyzableFields())))
.withValue("excluded_fields", c.getExcludedFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getExcludedFields()))));
}
}
return (rows);
}
/*******************************************************************************
** Table names that already have a settings row (read as the signed-in user).
*******************************************************************************/
private static Set<String> existingTables() throws QException
{
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG).withFilter(new QQueryFilter());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
Set<String> existing = new HashSet<>();
new QueryAction().execute(input).getRecords().forEach(row -> existing.add(row.getValueString("table_name")));
return (existing);
}
/*******************************************************************************
** Extract: the missing rows, computed (no query of the selected records).
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input) throws QException
{
return (missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()).size());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()))
{
getRecordPipe().addRecord(row);
}
}
}
/*******************************************************************************
** Transform: lists what will be added for the review screen.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine added = line(Status.OK, "table will get a settings row", "tables will get a settings row",
"table got a settings row", "tables got a settings row");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
added.incrementCount();
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
added.addSelfToListIfAnyCount(lines);
if(lines.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every analyzable table already has a settings row."));
}
return (lines);
}
}
/*******************************************************************************
** Load: insert through the standard action with the user's own permissions.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine created = line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
InsertInput insert = new InsertInput(DataAnalysisTables.TABLE_CONFIG).withRecords(input.getRecords());
insert.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
for(QRecord record : new InsertAction().execute(insert).getRecords())
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw (new QException("Could not add analysis settings: " + record.getErrors()));
}
created.incrementCount();
output.getRecords().add(record);
}
DataAnalysisRuntime.service().getConfigResolver().invalidate();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
created.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** A summary line with its four messages.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
{
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(singularFuture);
line.setPluralFutureMessage(pluralFuture);
line.setSingularPastMessage(singularPast);
line.setPluralPastMessage(pluralPast);
return (line);
}
}
@@ -0,0 +1,304 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.saved;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.PivotHeader;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput;
import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportDestination;
import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportField;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportView;
import com.kingsrook.qqq.backend.core.model.metadata.reporting.ReportType;
/*******************************************************************************
** Export already-authorized typed results using core CSV and XLSX streamers.
*******************************************************************************/
public final class AnalysisExport
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AnalysisExport()
{
}
/*******************************************************************************
** A result table, also reused by scheduled HTML summaries.
*******************************************************************************/
public record ResultTable(List<String> columns, List<List<String>> rows)
{
}
/*******************************************************************************
** Download bytes and metadata; filenames are fixed rather than user supplied.
*******************************************************************************/
public record File(byte[] bytes, String mimeType, String filename)
{
}
/*******************************************************************************
** Accept only the two documented export formats.
*******************************************************************************/
public static ReportFormat format(String value) throws QBadRequestException
{
if("csv".equalsIgnoreCase(value))
{
return (ReportFormat.CSV);
}
if("xlsx".equalsIgnoreCase(value))
{
return (ReportFormat.XLSX);
}
throw new QBadRequestException("format must be csv or xlsx.");
}
/*******************************************************************************
** Core's report streamers accept computed records without a source-table query.
*******************************************************************************/
public static File file(SavedResult result, String format) throws QException
{
ReportFormat type = format(format);
ResultTable table = table(result);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
var destination = new ReportDestination().withReportFormat(type).withReportOutputStream(bytes);
var view = new QReportView().withName("analysis").withLabel("Analysis").withType(ReportType.TABLE);
List<QFieldMetaData> fields = new ArrayList<>();
for(int i = 0; i < table.columns().size(); i++)
{
fields.add(new QFieldMetaData("c" + i, QFieldType.STRING).withLabel(table.columns().get(i)));
view.withColumn(new QReportField("c" + i).withLabel(table.columns().get(i)).withType(QFieldType.STRING));
}
var streamer = type.newReportStreamer();
streamer.preRun(destination, List.of(view));
streamer.start(new ExportInput().withReportDestination(destination), fields, "Analysis", view);
List<QRecord> records = new ArrayList<>();
for(List<String> row : table.rows())
{
QRecord record = new QRecord();
for(int i = 0; i < row.size(); i++)
{
record.withValue("c" + i, row.get(i));
}
records.add(record);
}
for(String warning : result.warnings())
{
records.add(new QRecord().withValue("c0", "Warning: " + warning));
}
streamer.addRecords(records);
streamer.finish();
return (new File(bytes.toByteArray(), type.getMimeType(), "analysis." + type.getExtension()));
}
/*******************************************************************************
** Preserve formatted values and suppressed cells; never export underlying raw data.
*******************************************************************************/
public static ResultTable table(SavedResult result)
{
List<String> columns = new ArrayList<>(List.of("Section", "Metric", "Value"));
List<List<String>> rows = new ArrayList<>();
if(result.columnProfile() != null)
{
var p = result.columnProfile();
metrics(rows, "Counts", p.getCounts());
if(p.getNumeric() != null)
{
metrics(rows, "Numeric", p.getNumeric().getMetrics());
}
if(p.getText() != null)
{
metrics(rows, "Text", p.getText().getMetrics());
}
if(p.getDates() != null)
{
metrics(rows, "Dates", p.getDates().getMetrics());
}
if(p.getBooleans() != null)
{
metrics(rows, "Boolean", p.getBooleans());
}
p.getTopValues().forEach(v -> rows.add(row("Top values", v.getLabel(), v.getCount())));
}
else if(result.tableProfile() != null)
{
var p = result.tableProfile();
metrics(rows, "Table", List.of(p.getRowCount()));
for(var field : p.getFields())
{
rows.add(row(field.getLabel(), "Completeness", field.getCompleteness()));
rows.add(row(field.getLabel(), "Distinct", field.getDistinct()));
rows.add(row(field.getLabel(), "Uniqueness", field.getUniqueness()));
}
}
else if(result.pivot() != null)
{
var p = result.pivot();
columns = new ArrayList<>(List.of("Row"));
for(var headers : p.getColumnHeaders())
{
for(var measure : p.getMeasures())
{
columns.add(label(headers) + " / " + measure.getLabel());
}
}
for(int r = 0; r < p.getRowHeaders().size(); r++)
{
List<String> row = new ArrayList<>();
row.add(label(p.getRowHeaders().get(r)));
for(int c = 0; c < p.getColumnHeaders().size(); c++)
{
final int ri = r;
final int ci = c;
var cell = p.getCells().stream().filter(x -> x.getRowIndex() == ri && x.getColumnIndex() == ci).findFirst();
for(int m = 0; m < p.getMeasures().size(); m++)
{
row.add(cell.isPresent() ? value(cell.get().getValues().get(m)) : "");
}
}
rows.add(row);
}
}
else if(result.compare() != null)
{
columns = List.of("Field", "Value", "Metric", "A", "B", "Delta", "Percent delta");
for(var field : result.compare().getFields())
{
for(var comparison : field.getValues())
{
for(var metric : comparison.getSideA().getMetrics())
{
var b = comparison.getSideB().getMetrics().stream().filter(m -> Objects.equals(m.getKey(), metric.getKey())).findFirst().orElse(null);
rows.add(row(field.getField(), comparison.getLabel(), metric.getLabel(), value(metric), value(b), value(comparison.getDelta()), value(comparison.getPercentDelta())));
}
}
}
}
else if(result.correlation() != null)
{
columns = List.of("X", "Y", "Paired rows", "Correlation");
result.correlation().getCells().forEach(c -> rows.add(row(c.getXField(), c.getYField(), c.getN(), c.getR())));
}
else if(result.crosstab() != null)
{
columns = List.of("Row", "Column", "Count", "Row share", "Column share");
var p = result.crosstab();
p.getCells().forEach(c -> rows.add(row(p.getRows().get(c.getRowIndex()).getLabel(), p.getColumns().get(c.getColumnIndex()).getLabel(),
value(c.getCount()), value(c.getRowShare()), value(c.getColumnShare()))));
}
else if(result.shift() != null)
{
for(var bucket : result.shift().getBuckets())
{
metrics(rows, bucket.getLabel(), bucket.getMetrics());
}
if(result.shift().getPsi() != null)
{
metrics(rows, "Shift", List.of(result.shift().getPsi()));
}
}
return (new ResultTable(columns, rows));
}
/*******************************************************************************
** Headline metrics shared by standard chart widgets and email summaries.
*******************************************************************************/
public static List<Metric> headlines(SavedResult result)
{
List<Metric> metrics = new ArrayList<>();
if(result.columnProfile() != null)
{
metrics.addAll(result.columnProfile().getCounts());
if(result.columnProfile().getNumeric() != null)
{
metrics.addAll(result.columnProfile().getNumeric().getMetrics().stream()
.filter(m -> List.of("mean", "median", "min", "max").contains(m.getKey())).toList());
}
}
else if(result.tableProfile() != null)
{
metrics.add(result.tableProfile().getRowCount());
}
else if(result.pivot() != null)
{
metrics.addAll(result.pivot().getGrandTotals());
}
else if(result.compare() != null)
{
result.compare().getFields().stream().limit(4).forEach(f -> f.getValues().stream().limit(1).forEach(v -> metrics.add(v.getDelta())));
}
else if(result.shift() != null)
{
metrics.add(result.shift().getPsi());
}
else
{
int count = result.correlation() != null ? result.correlation().getFields().size() : result.crosstab() != null ? result.crosstab().getCells().size() : 0;
if(result.correlation() != null || result.crosstab() != null)
{
metrics.add(new Metric().withKey("visible").withLabel(result.correlation() != null ? "Numeric fields" : "Visible combinations")
.withValue(count).withValueType(com.kingsrook.qbits.dataanalysis.result.ValueType.COUNT).withFormatted(String.valueOf(count)));
}
}
return (metrics.stream().filter(Objects::nonNull).toList());
}
/*******************************************************************************
** No nulls in report cells; unknown and suppressed are distinct from zero.
*******************************************************************************/
private static List<String> row(Object... values)
{
return (java.util.Arrays.stream(values).map(v -> v == null ? "" : String.valueOf(v)).toList());
}
/*******************************************************************************
** Render a tuple using the engine's already-authorized display labels.
*******************************************************************************/
private static String label(List<PivotHeader> headers)
{
return (headers.stream().map(PivotHeader::getLabel).collect(java.util.stream.Collectors.joining(" / ")));
}
/*******************************************************************************
** Metrics always use their semantic formatted value.
*******************************************************************************/
public static String value(Metric metric)
{
return (metric == null ? "" : Boolean.TRUE.equals(metric.getSuppressed()) ? "Suppressed" : Objects.toString(metric.getFormatted(), ""));
}
/*******************************************************************************
** Profile metrics become one row per headline statistic.
*******************************************************************************/
private static void metrics(List<List<String>> rows, String section, List<Metric> metrics)
{
metrics.forEach(m -> rows.add(row(section, m.getLabel(), value(m))));
}
}
@@ -0,0 +1,151 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.saved;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.dataanalysis.DataAnalysisService;
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Saved definitions always execute in the calling context, including shared reads.
*******************************************************************************/
public class SavedAnalyses
{
private final DataAnalysisService service;
/*******************************************************************************
** Use the instance's existing engine and cache policy.
*******************************************************************************/
public SavedAnalyses(DataAnalysisService service)
{
this.service = service;
}
/*******************************************************************************
** Standard core READ and record locks precede source-table authorization.
*******************************************************************************/
public SavedResult run(Serializable id) throws QException
{
return (SavedDefinition.parse(WatchRecords.get(DataAnalysisTables.SAVED, id)).run(service, false));
}
/*******************************************************************************
** Readable list rows contain presentation metadata, never cached owner results.
*******************************************************************************/
public record Entry(Serializable id, String name, String kind, String tableName, String tableLabel, String owner,
Boolean shared, Boolean starred, Instant updated)
{
}
/*******************************************************************************
** Only stars belonging to this viewer decorate their core-visible saved rows.
*******************************************************************************/
public List<Entry> list() throws QException
{
Set<Serializable> stars = new HashSet<>();
for(QRecord row : query(DataAnalysisTables.SAVED_STAR, DrillFilters.condition("user_id", QCriteriaOperator.EQUALS, user())))
{
stars.add(row.getValue("saved_id"));
}
List<Entry> result = new ArrayList<>();
for(QRecord row : query(DataAnalysisTables.SAVED, new QQueryFilter().withOrderBy(new QFilterOrderBy("modifyDate", false))))
{
String tableName = row.getValueString("table_name");
var table = QContext.getQInstance().getTable(tableName);
result.add(new Entry(row.getValue("id"), row.getValueString("name"), row.getValueString("kind"), tableName,
table == null ? tableName : table.getLabel(), row.getValueString("owner_user_id"), row.getValueBoolean("shared"),
stars.contains(row.getValue("id")), row.getValueInstant("modifyDate")));
}
return (result);
}
/*******************************************************************************
** Idempotent per-runtime PUT/DELETE, with standard insert/delete permissions.
*******************************************************************************/
public synchronized Boolean star(Serializable id, boolean starred) throws QException
{
WatchRecords.get(DataAnalysisTables.SAVED, id);
QQueryFilter filter = DrillFilters.and(DrillFilters.condition("saved_id", QCriteriaOperator.EQUALS, id),
DrillFilters.condition("user_id", QCriteriaOperator.EQUALS, user()));
List<QRecord> existing = query(DataAnalysisTables.SAVED_STAR, filter);
if(starred && existing.isEmpty())
{
InsertInput input = new InsertInput(DataAnalysisTables.SAVED_STAR).withRecords(List.of(new QRecord().withValue("saved_id", id).withValue("user_id", user())));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QRecord record = new InsertAction().execute(input).getRecords().getFirst();
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw new QException("Could not save the star.");
}
}
else if(!starred && !existing.isEmpty())
{
DeleteInput input = new DeleteInput(DataAnalysisTables.SAVED_STAR).withPrimaryKeys(existing.stream().map(r -> r.getValue("id")).toList());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new DeleteAction().execute(input);
}
return (starred);
}
/*******************************************************************************
** Shared enforced query helper for visible records, not a security-filter layer.
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput input = new QueryInput(table).withShouldFetchHeavyFields(true).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** An authenticated identity is required even in an unprotected host app.
*******************************************************************************/
public static String user() throws QPermissionDeniedException
{
var session = QContext.getQSession();
if(session == null || session.getUser() == null || session.getUser().getIdReference() == null)
{
throw new QPermissionDeniedException("Sign in to use saved analyses.");
}
return (session.getUser().getIdReference());
}
}
@@ -0,0 +1,91 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.saved;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
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.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Validate all core CRUD and maintain the read-lock projection atomically.
*******************************************************************************/
public class SavedCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Inserts cannot forge an owner or the derived shared-read key.
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean preview) throws QException
{
for(QRecord row : records)
{
validate(row, row);
}
return (records);
}
/*******************************************************************************
** Merge partial edits so changing shared or owner also changes the read lock.
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
for(QRecord patch : records)
{
QRecord merged = new QRecord(WatchRecords.get(DataAnalysisTables.SAVED, patch.getValue("id")));
merged.getValues().putAll(patch.getValues());
validate(patch, merged);
}
return (records);
}
/*******************************************************************************
** Report a plain validation message on the standard record editor.
*******************************************************************************/
private void validate(QRecord output, QRecord merged)
{
try
{
String owner = merged.getValueString("owner_user_id");
var session = QContext.getQSession();
if(owner == null || owner.isBlank() || session == null || (!(session.getSecurityKeyValues() != null && session.getSecurityKeyValues().getOrDefault(DataAnalysisPermissions.ADMIN, List.of()).contains(true))
&& (session.getUser() == null || !owner.equals(session.getUser().getIdReference()))))
{
throw new QException("Only an administrator may assign another analysis owner.");
}
SavedDefinition.parse(merged);
output.withValue("read_owner_user_id", Boolean.TRUE.equals(merged.getValueBoolean("shared")) ? null : owner);
}
catch(QException e)
{
output.addError(new BadInputStatusMessage(e.getMessage()));
}
}
}
@@ -0,0 +1,313 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.saved;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.dataanalysis.DataAnalysisService;
import com.kingsrook.qbits.dataanalysis.result.AnalysisRequest;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.CompareRequest;
import com.kingsrook.qbits.dataanalysis.result.CorrelationRequest;
import com.kingsrook.qbits.dataanalysis.result.CrosstabRequest;
import com.kingsrook.qbits.dataanalysis.result.PivotRequest;
import com.kingsrook.qbits.dataanalysis.result.ShiftRequest;
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** One parser for persisted definitions, exports, schedules and SQL requests.
*******************************************************************************/
public record SavedDefinition(String kind, AnalysisRequest request)
{
private static final Map<String, Class<? extends AnalysisRequest>> TYPES = Map.of(
"COLUMN_PROFILE", ColumnProfileRequest.class, "TABLE_PROFILE", TableProfileRequest.class,
"PIVOT", PivotRequest.class, "COMPARE", CompareRequest.class, "CORRELATION", CorrelationRequest.class,
"CROSSTAB", CrosstabRequest.class, "SHIFT", ShiftRequest.class);
private static final Set<String> FIELD_KEYS = Set.of("fieldName", "field", "dateField", "rowField", "columnField");
public static final String FIELD_WARNING = "Some fields are not available to you";
/*******************************************************************************
** The definition owns the typed filter; legacy filter_json remains supported.
*******************************************************************************/
public static SavedDefinition parse(QRecord row) throws QException
{
Map<String, Object> body = object(row.getValueString("definition_json"));
if(body.get("filter") == null && row.getValueString("filter_json") != null)
{
body.put("filter", object(row.getValueString("filter_json")));
}
return (parse(row.getValueString("kind"), row.getValueString("table_name"), body));
}
/*******************************************************************************
** Reject malformed JSON with a plain, stable message.
*******************************************************************************/
@SuppressWarnings("unchecked")
public static Map<String, Object> object(String json) throws QBadRequestException
{
try
{
Object value = JsonUtils.toObject(json, Object.class);
if(value instanceof Map<?, ?> map)
{
return (new LinkedHashMap<>((Map<String, Object>) map));
}
}
catch(Exception expected)
{
// Normalize parser diagnostics; never return raw saved JSON in errors.
}
throw new QBadRequestException("definition_json must be a JSON object.");
}
/*******************************************************************************
** Validate metadata existence before saving, without querying source records.
*******************************************************************************/
public static SavedDefinition parse(String kind, String table, Map<String, Object> body) throws QException
{
if(!TYPES.containsKey(kind == null ? "" : kind))
{
throw new QBadRequestException("Choose a supported analysis kind.");
}
if(table == null || QContext.getQInstance().getTable(table) == null)
{
throw new QBadRequestException("Choose an existing analysis table.");
}
if(body.get("tableName") != null && !table.equals(body.get("tableName")))
{
throw new QBadRequestException("The definition table must match table_name.");
}
Map<String, Object> copy = new LinkedHashMap<>(body);
copy.put("tableName", table);
try
{
validateJson(copy);
AnalysisRequest request = JsonUtils.toObject(JsonUtils.toJson(copy), TYPES.get(kind));
requireShape(request);
for(String field : fields(copy))
{
String[] parts = field.split("\\.", 2);
var metadata = QContext.getQInstance().getTable(parts.length == 2 ? parts[0] : table);
if(metadata == null || metadata.getField(parts.length == 2 ? parts[1] : field) == null)
{
throw new IllegalArgumentException("A definition field does not exist.");
}
}
return (new SavedDefinition(kind, request));
}
catch(Exception e)
{
throw new QBadRequestException("Invalid analysis definition: " + (e instanceof IllegalArgumentException ? e.getMessage() : "check the request fields and types."));
}
}
/*******************************************************************************
** Reject coercible scalar types and malformed predicates before Jackson conversion.
*******************************************************************************/
private static void validateJson(Object value)
{
if(value instanceof Map<?, ?> map)
{
for(var entry : map.entrySet())
{
String name = String.valueOf(entry.getKey());
Object item = entry.getValue();
if(item == null)
{
continue;
}
if((FIELD_KEYS.contains(name) || Set.of("tableName", "bucket", "operator").contains(name)) && !(item instanceof String))
{
throw new IllegalArgumentException("Field names and operators must be strings.");
}
if(name.equals("fields") && (!(item instanceof List<?> list) || list.stream().anyMatch(v -> !(v instanceof String))))
{
throw new IllegalArgumentException("fields must be an array of field names.");
}
if(Set.of("filter", "filterA", "filterB").contains(name) && !(item instanceof Map<?, ?>))
{
throw new IllegalArgumentException("Filters must be query filter objects.");
}
if(name.equals("criteria") && (!(item instanceof List<?> list) || list.stream().anyMatch(v -> !(v instanceof Map<?, ?> c)
|| !(c.get("fieldName") instanceof String) || !(c.get("operator") instanceof String))))
{
throw new IllegalArgumentException("Filter criteria require a field and operator.");
}
if(Set.of("exact", "showTotals").contains(name) && !(item instanceof Boolean))
{
throw new IllegalArgumentException("Flags must be booleans.");
}
validateJson(item);
}
}
else if(value instanceof List<?> list)
{
list.forEach(SavedDefinition::validateJson);
}
}
/*******************************************************************************
** Required selections must not turn an invalid draft into a different query.
*******************************************************************************/
private static void requireShape(AnalysisRequest request)
{
boolean valid = switch(request)
{
case ColumnProfileRequest r -> r.getFieldName() != null;
case TableProfileRequest r -> r.getFields() == null || !r.getFields().isEmpty();
case CompareRequest r -> r.getFields() != null && !r.getFields().isEmpty();
case CorrelationRequest r -> r.getFields() != null && r.getFields().size() >= 2;
case CrosstabRequest r -> r.getRowField() != null && r.getColumnField() != null;
case ShiftRequest r -> r.getField() != null && r.getDateField() != null;
case PivotRequest r -> r.getValues() != null && !r.getValues().isEmpty()
&& r.getValues().stream().allMatch(v -> v.getOperator() != null && (v.getOperator().name().equals("COUNT") || v.getField() != null));
default -> false;
};
if(!valid)
{
throw new IllegalArgumentException("Choose the fields and measures required by this analysis kind.");
}
}
/*******************************************************************************
** Include selected and filter fields, including both compare populations.
*******************************************************************************/
public static Set<String> fields(Object value)
{
Set<String> names = new LinkedHashSet<>();
if(value instanceof Map<?, ?> map)
{
for(var entry : map.entrySet())
{
if(FIELD_KEYS.contains(entry.getKey()) && entry.getValue() instanceof String name)
{
names.add(name);
}
else if("fields".equals(entry.getKey()) && entry.getValue() instanceof List<?> list)
{
list.forEach(name -> names.add((String) name));
}
else
{
names.addAll(fields(entry.getValue()));
}
}
}
else if(value instanceof List<?> list)
{
list.forEach(item -> names.addAll(fields(item)));
}
return (names);
}
/*******************************************************************************
** A fresh tree makes field reduction request-local and cannot change storage.
*******************************************************************************/
public Map<String, Object> body() throws QException
{
return (object(JsonUtils.toJson(request)));
}
/*******************************************************************************
** Filter rights are not weakened: an unavailable predicate prevents execution.
** Required denied axes return an empty typed envelope rather than change meaning.
*******************************************************************************/
@SuppressWarnings("unchecked")
public SavedResult run(DataAnalysisService service, boolean strict) throws QException
{
service.capabilities(request.getTableName());
Map<String, Object> body = body();
Set<String> denied = new LinkedHashSet<>();
for(String field : fields(body))
{
if(!service.isAvailableField(request.getTableName(), field))
{
denied.add(field);
}
}
if(strict && !denied.isEmpty())
{
throw new QBadRequestException(FIELD_WARNING);
}
List<String> warnings = denied.isEmpty() ? List.of() : List.of(FIELD_WARNING);
for(String filter : List.of("filter", "filterA", "filterB"))
{
if(fields(body.get(filter)).stream().anyMatch(denied::contains))
{
return (SavedResult.of(kind, null, warnings));
}
}
if(body.get("fields") instanceof List<?> list)
{
body.put("fields", list.stream().filter(f -> !denied.contains(f)).toList());
}
for(String axis : List.of("rows", "columns", "values"))
{
if(body.get(axis) instanceof List<?> list)
{
body.put(axis, list.stream().filter(item -> fields(item).stream().noneMatch(denied::contains)).toList());
}
}
if(FIELD_KEYS.stream().anyMatch(key -> denied.contains(body.get(key))))
{
return (SavedResult.of(kind, null, warnings));
}
AnalysisRequest reduced;
try
{
reduced = JsonUtils.toObject(JsonUtils.toJson(body), TYPES.get(kind));
requireShape(reduced);
}
catch(Exception e)
{
return (SavedResult.of(kind, null, warnings));
}
Object result = switch(reduced)
{
case ColumnProfileRequest r ->
{
var backend = service.backend(r.getTableName());
yield service.compute(r, backend, backend.scope(r.getTableName(), r.getFilter(), Boolean.TRUE.equals(r.getExact())));
}
case TableProfileRequest r ->
{
var backend = service.backend(r.getTableName());
yield service.compute(r, backend, backend.scope(r.getTableName(), r.getFilter(), Boolean.TRUE.equals(r.getExact())));
}
case PivotRequest r -> service.pivot(r);
case CompareRequest r -> service.compare(r);
case CorrelationRequest r -> service.correlation(r);
case CrosstabRequest r -> service.crosstab(r);
case ShiftRequest r -> service.shift(r);
default -> throw new QBadRequestException("Unsupported analysis kind.");
};
return (SavedResult.of(kind, result, warnings));
}
}
@@ -0,0 +1,60 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.saved;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
import com.kingsrook.qbits.dataanalysis.result.CompareResult;
import com.kingsrook.qbits.dataanalysis.result.CorrelationResult;
import com.kingsrook.qbits.dataanalysis.result.CrosstabResult;
import com.kingsrook.qbits.dataanalysis.result.PivotResult;
import com.kingsrook.qbits.dataanalysis.result.ShiftResult;
import com.kingsrook.qbits.dataanalysis.result.TableProfile;
/*******************************************************************************
** Discriminated result: exactly the member named by kind is populated when runnable.
*******************************************************************************/
public record SavedResult(String kind, List<String> warnings, ColumnProfile columnProfile, TableProfile tableProfile,
PivotResult pivot, CompareResult compare, CorrelationResult correlation, CrosstabResult crosstab, ShiftResult shift)
{
/*******************************************************************************
** Preserve typed engine results without an untyped JSON payload.
*******************************************************************************/
public static SavedResult of(String kind, Object result, List<String> warnings)
{
java.util.ArrayList<String> notices = new java.util.ArrayList<>(warnings);
if(result instanceof ColumnProfile p)
{
notices.addAll(p.getWarnings());
}
else if(result instanceof TableProfile p)
{
notices.addAll(p.getWarnings());
}
else if(result instanceof PivotResult p)
{
notices.addAll(p.getWarnings());
}
return (new SavedResult(kind, notices.stream().distinct().toList(), result instanceof ColumnProfile p ? p : null, result instanceof TableProfile p ? p : null,
result instanceof PivotResult p ? p : null, result instanceof CompareResult p ? p : null,
result instanceof CorrelationResult p ? p : null, result instanceof CrosstabResult p ? p : null, result instanceof ShiftResult p ? p : null));
}
}
@@ -0,0 +1,147 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.saved;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisService;
import com.kingsrook.qqq.backend.core.actions.interfaces.AggregateInterface;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOutput;
/*******************************************************************************
** Request-local capture of core's actual aggregate SQL, never a separate SQL planner.
*******************************************************************************/
public final class SqlHandoff
{
private static final ThreadLocal<List<Statement>> CAPTURE = new ThreadLocal<>();
private static final String NOTE = "Record security filters are applied by QQQ at run time. This SQL is for reading; ? placeholders remain bound by QQQ.";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SqlHandoff()
{
}
/*******************************************************************************
** Statement ranking prefers the main grouped matrix, then the widest metric batch.
*******************************************************************************/
private record Statement(String table, String sql, int dimensions, int measures)
{
}
/*******************************************************************************
** Transported capability, statement and security note.
*******************************************************************************/
public record Result(Boolean available, String sql, String note)
{
}
/*******************************************************************************
** Installation plus its configured access permission are both required.
*******************************************************************************/
public static boolean installedAndAllowed()
{
var instance = QContext.getQInstance();
var app = instance.getApp("sqlWorkbench");
boolean installed = app != null || instance.getTables().keySet().stream().anyMatch(t -> t.startsWith("sql_workbench_"));
String base = app != null && app.getPermissionRules() != null && app.getPermissionRules().getPermissionBaseName() != null
? app.getPermissionRules().getPermissionBaseName() : "sql_workbench";
return (installed && QContext.getQSession() != null && QContext.getQSession().hasPermission(base + ".hasAccess"));
}
/*******************************************************************************
** The backend metadata inheritance check avoids a production RDBMS dependency.
*******************************************************************************/
public static Result execute(SavedDefinition definition, DataAnalysisService service) throws QException
{
if(!installedAndAllowed())
{
return (new Result(false, null, "SQL Workbench is not installed or available to you."));
}
service.capabilities(definition.request().getTableName());
var table = QContext.getQInstance().getTable(definition.request().getTableName());
Class<?> type = QContext.getQInstance().getBackend(table.getBackendName()).getClass();
boolean rdbms = false;
while(type != null)
{
rdbms |= type.getSimpleName().equals("RDBMSBackendMetaData");
type = type.getSuperclass();
}
if(!rdbms)
{
return (new Result(false, null, "SQL is unavailable for a non-RDBMS backend."));
}
List<Statement> statements = new ArrayList<>();
CAPTURE.set(statements);
try
{
definition.run(service, true);
}
finally
{
CAPTURE.remove();
}
java.util.Comparator<Statement> ranking = List.of("PIVOT", "CROSSTAB", "SHIFT").contains(definition.kind())
? java.util.Comparator.comparingInt(Statement::dimensions).thenComparingInt(Statement::measures)
: java.util.Comparator.comparingInt(Statement::measures).thenComparingInt(s -> -s.dimensions());
Statement main = statements.stream().filter(s -> s.table().equals(table.getName())).max(ranking).orElse(null);
return (main == null ? new Result(false, null, "Core did not expose SQL for this aggregate. " + NOTE) : new Result(true, main.sql(), NOTE));
}
/*******************************************************************************
** Execute the ordinary authorized core action, then read its backend QueryStat.
** Core exposes QueryStat on the backend but not its action reference; this narrow
** bridge never changes action state, global logging, or QueryStatManager settings.
*******************************************************************************/
public static AggregateOutput aggregate(AggregateInput input) throws QException
{
if(CAPTURE.get() != null)
{
input.setTableMetaData(input.getTable().clone().withCapability(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.QUERY_STATS));
}
AggregateAction action = new AggregateAction();
AggregateOutput output = action.execute(input);
if(CAPTURE.get() != null)
{
try
{
Field field = AggregateAction.class.getDeclaredField("aggregateInterface");
field.setAccessible(true);
var stat = ((AggregateInterface) field.get(action)).getQueryStat();
if(stat != null && stat.getQueryText() != null)
{
CAPTURE.get().add(new Statement(input.getTableName(), stat.getQueryText(), input.getGroupBys().size(), input.getAggregates().size()));
}
}
catch(ReflectiveOperationException | RuntimeException expected)
{
// A future core change makes handoff unavailable rather than invent SQL.
}
}
return (output);
}
}
@@ -0,0 +1,116 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
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.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Core CRUD validates recipients and computes the next occurrence on configuration edits.
*******************************************************************************/
public class ScheduleCustomizer implements TableCustomizerInterface
{
private static final Set<String> CONFIG = Set.of("saved_id", "cron", "frequency", "day", "time", "time_zone", "enabled", "recipients", "format");
/*******************************************************************************
** Disabled empty drafts may be completed later.
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean preview) throws QException
{
for(QRecord row : records)
{
validate(row, row);
}
return (records);
}
/*******************************************************************************
** Status-only scheduler writes do not move the next occurrence twice.
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
for(QRecord patch : records)
{
if(patch.getValues().keySet().stream().anyMatch(CONFIG::contains))
{
QRecord row = new QRecord(WatchRecords.get(DataAnalysisTables.SCHEDULE, patch.getValue("id")));
row.getValues().putAll(patch.getValues());
validate(patch, row);
}
}
return (records);
}
/*******************************************************************************
** User ids are stored as a JSON array, not an unchecked email recipient list.
*******************************************************************************/
public static List<String> recipients(QRecord row) throws QException
{
try
{
Object value = JsonUtils.toObject(row.getValueString("recipients"), Object.class);
if(value instanceof List<?> list && !list.isEmpty() && list.size() <= 100
&& list.stream().allMatch(v -> v instanceof String s && !s.isBlank()))
{
return (list.stream().map(String.class::cast).distinct().toList());
}
}
catch(Exception expected)
{
// Use the same plain error for JSON and shape errors.
}
throw new QException("Recipients must be a JSON array of 1 to 100 user ids.");
}
/*******************************************************************************
** Scheduling references a real, visible saved analysis.
*******************************************************************************/
private void validate(QRecord output, QRecord row)
{
try
{
SavedDefinition.parse(WatchRecords.get(DataAnalysisTables.SAVED, row.getValue("saved_id")));
if(Boolean.TRUE.equals(row.getValueBoolean("enabled")) || row.getValueString("frequency") != null || row.getValueString("cron") != null)
{
recipients(row);
output.withValue("next_run_at", ScheduleTiming.next(row, Instant.now()));
}
}
catch(QException e)
{
output.addError(new BadInputStatusMessage(e.getMessage()));
}
}
}
@@ -0,0 +1,305 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisService;
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qbits.dataanalysis.saved.SavedResult;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Attachment;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
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.
*******************************************************************************/
public class ScheduleRunner
{
public static final String ALERT_TYPE = "data.analysis.schedule.failed";
private final DataAnalysisQBitConfig config;
private final DataAnalysisService service;
/*******************************************************************************
** One instance serializes manual and automatic delivery on this application node.
*******************************************************************************/
public ScheduleRunner(DataAnalysisQBitConfig config, DataAnalysisService service)
{
this.config = config;
this.service = service;
}
/*******************************************************************************
** Enumerate enabled due rows with a maintenance session restricted to metadata.
*******************************************************************************/
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)
{
maintenance.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
}
int[] count = {0};
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), maintenance), () ->
{
var filter = DrillFilters.and(DrillFilters.condition("enabled", QCriteriaOperator.EQUALS, true),
DrillFilters.condition("next_run_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now));
for(QRecord row : SavedAnalyses.query(DataAnalysisTables.SCHEDULE, filter))
{
sendNow(row.getValue("id"), now);
count[0]++;
}
});
return (count[0]);
}
/*******************************************************************************
** Manual delivery is authorized before source work; all attempts record their outcome.
*******************************************************************************/
public synchronized String sendNow(Serializable id, Instant now) throws QException
{
UpdateInput authorization = new UpdateInput(DataAnalysisTables.SCHEDULE);
authorization.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
if(!PermissionsHelper.hasTablePermission(authorization, DataAnalysisTables.SCHEDULE, TablePermissionSubType.EDIT))
{
throw new QPermissionDeniedException("Schedule editing is unavailable.");
}
QRecord schedule = WatchRecords.get(DataAnalysisTables.SCHEDULE, id);
String status;
boolean failed = false;
try
{
status = deliver(schedule);
}
catch(Exception e)
{
failed = true;
status = "Failed: " + (e instanceof QException ? e.getMessage() : "Delivery unavailable; check the host configuration.");
}
Instant next = null;
try
{
next = ScheduleTiming.next(schedule, now);
}
catch(QException e)
{
failed = true;
status += "; " + e.getMessage();
}
QRecord patch = new QRecord().withValue("id", id).withValue("last_run_at", now).withValue("last_status", status).withValue("next_run_at", next);
UpdateInput input = new UpdateInput(DataAnalysisTables.SCHEDULE).withRecords(List.of(patch));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QRecord stored = new UpdateAction().execute(input).getRecords().getFirst();
if(stored.getErrors() != null && !stored.getErrors().isEmpty())
{
throw new QException("Schedule status could not be stored.");
}
if(failed)
{
QAlerts.raise(new AlertSignal(ALERT_TYPE).withDedupKey("data_analysis_schedule:" + id).withSeverity(AlertSeverity.ERROR)
.withTitle("Analysis schedule failed").withSummary(status).withRecordRef(AlertRecordRef.of(DataAnalysisTables.SCHEDULE, id)));
}
return (status);
}
/*******************************************************************************
** The host resolves identities; an unavailable identity never becomes a system user.
*******************************************************************************/
private QSession session(String id) throws QException
{
QSession session;
try
{
session = config.getOwnerSessionSupplier() == null
? new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), id)
: config.getOwnerSessionSupplier().apply(id);
}
catch(Exception e)
{
throw new QException("User session unavailable.");
}
if(session == null || session.getUser() == null || !Objects.equals(id, session.getUser().getIdReference())
|| session.getSecurityKeyValues() == null || !session.getSecurityKeyValues().getOrDefault(DataAnalysisTables.USER_KEY, List.of()).contains(id))
{
throw new QException("User session unavailable.");
}
return (session);
}
/*******************************************************************************
** Re-run for each recipient to respect their record locks, not just table grants.
*******************************************************************************/
private String deliver(QRecord schedule) throws QException
{
if(config.getMessagingProviderName() == null || QContext.getQInstance().getMessagingProvider(config.getMessagingProviderName()) == null)
{
throw new QException("No messaging provider configured.");
}
if(config.getFromAddress() == null || config.getRecipientAddressSupplier() == null)
{
throw new QException("Configure fromAddress and recipientAddressSupplier for scheduled email.");
}
if(List.of("CSV", "XLSX").contains(Objects.toString(schedule.getValueString("format"), "")))
{
var provider = new com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher()
.getMessagingProviderInterface(QContext.getQInstance().getMessagingProvider(config.getMessagingProviderName()));
if(provider.getClass().equals(com.kingsrook.qqq.backend.core.model.metadata.messaging.email.EmailMessagingProvider.class))
{
throw new QException("Core EMAIL provider does not support attachments; configure an attachment-capable messaging provider.");
}
}
QRecord saved = WatchRecords.get(DataAnalysisTables.SAVED, schedule.getValue("saved_id"));
String owner = saved.getValueString("owner_user_id");
SavedDefinition[] definition = new SavedDefinition[1];
SavedResult[] ownerResult = new SavedResult[1];
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), session(owner)), () ->
{
definition[0] = SavedDefinition.parse(WatchRecords.get(DataAnalysisTables.SAVED, saved.getValue("id")));
if("TABLE_PROFILE".equals(definition[0].kind()) && definition[0].body().get("fields") == null)
{
var body = definition[0].body();
body.put("fields", service.capabilities(definition[0].request().getTableName()).getFields());
definition[0] = SavedDefinition.parse(definition[0].kind(), definition[0].request().getTableName(), body);
}
ownerResult[0] = definition[0].run(service, true);
});
List<String> skipped = new ArrayList<>();
List<String> failures = new ArrayList<>();
int sent = 0;
for(String recipient : ScheduleCustomizer.recipients(schedule))
{
SavedResult[] result = new SavedResult[1];
String address;
try
{
QSession viewer = session(recipient);
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), viewer), () ->
{
result[0] = recipient.equals(owner) ? ownerResult[0] : definition[0].run(service, true);
});
address = config.getRecipientAddressSupplier().apply(recipient);
if(address == null || address.isBlank())
{
throw new QException("No email address.");
}
}
catch(Exception e)
{
skipped.add(recipient);
continue;
}
try
{
SendMessageInput message = new SendMessageInput().withMessagingProviderName(config.getMessagingProviderName())
.withFrom(new Party().withAddress(config.getFromAddress())).withTo(new Party().withAddress(address))
.withSubject("Analysis: " + saved.getValueString("name"))
.withContent(new Content().withContentRole(EmailContentRole.HTML).withBody(html(saved.getValueString("name"), result[0])));
String format = schedule.getValueString("format");
if("CSV".equals(format) || "XLSX".equals(format))
{
var file = AnalysisExport.file(result[0], format);
message.withAttachment(new Attachment().withName(file.filename()).withContents(file.bytes()));
}
new SendMessageAction().execute(message);
sent++;
}
catch(Exception e)
{
failures.add(recipient);
}
}
String status = "Sent " + sent + "; skipped: " + (skipped.isEmpty() ? "none" : String.join(", ", skipped));
if(!failures.isEmpty())
{
throw new QException(status + "; delivery failed for: " + String.join(", ", failures));
}
return (status);
}
/*******************************************************************************
** Formatted result rows are escaped; pivots include only the first twenty rows.
*******************************************************************************/
public static String html(String name, SavedResult result)
{
var table = AnalysisExport.table(result);
StringBuilder html = new StringBuilder("<h2>").append(escape(name)).append("</h2>");
for(String warning : result.warnings())
{
html.append("<p>").append(escape(warning)).append("</p>");
}
html.append("<dl>");
for(var metric : AnalysisExport.headlines(result))
{
html.append("<dt>").append(escape(metric.getLabel())).append("</dt><dd>").append(escape(AnalysisExport.value(metric))).append("</dd>");
}
html.append("</dl><table><thead><tr>");
table.columns().forEach(c -> html.append("<th>").append(escape(c)).append("</th>"));
html.append("</tr></thead><tbody>");
for(var row : table.rows().stream().limit(20).toList())
{
html.append("<tr>");
row.forEach(value -> html.append("<td>").append(escape(value)).append("</td>"));
html.append("</tr>");
}
return (html.append("</tbody></table>").toString());
}
/*******************************************************************************
** Source labels and values are text, never trusted email markup.
*******************************************************************************/
private static String escape(String value)
{
return (Objects.toString(value, "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
}
@@ -0,0 +1,111 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.Date;
import java.util.TimeZone;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.quartz.CronExpression;
/*******************************************************************************
** Calendar schedules use local wall time; cron uses Quartz's existing parser.
*******************************************************************************/
public final class ScheduleTiming
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ScheduleTiming()
{
}
/*******************************************************************************
** Strictly after now; missing monthly dates clamp to that month's last day.
** Java resolves spring gaps forward and chooses the first autumn occurrence.
*******************************************************************************/
public static Instant next(QRecord row, Instant now) throws QBadRequestException
{
try
{
ZoneId zone = ZoneId.of(row.getValueString("time_zone") == null ? "UTC" : row.getValueString("time_zone"));
String cron = row.getValueString("cron");
if(cron != null && !cron.isBlank())
{
CronExpression expression = new CronExpression(cron);
expression.setTimeZone(TimeZone.getTimeZone(zone));
Date next = expression.getNextValidTimeAfter(Date.from(now));
if(next == null)
{
throw new IllegalArgumentException("Cron has no future occurrence.");
}
return (next.toInstant());
}
LocalTime time = LocalTime.parse(row.getValueString("time"));
String frequency = row.getValueString("frequency");
Integer day = row.getValueInteger("day");
LocalDate date = now.atZone(zone).toLocalDate();
for(int attempt = 0; attempt < 3; attempt++)
{
LocalDate candidate = switch(frequency)
{
case "DAILY" -> date;
case "WEEKLY" -> date.with(TemporalAdjusters.nextOrSame(java.time.DayOfWeek.of(day)));
case "MONTHLY" -> date.withDayOfMonth(Math.min(requireDay(day), date.lengthOfMonth()));
default -> throw new IllegalArgumentException("Choose DAILY, WEEKLY or MONTHLY.");
};
Instant instant = candidate.atTime(time).atZone(zone).toInstant();
if(instant.isAfter(now))
{
return (instant);
}
date = switch(frequency)
{
case "WEEKLY" -> candidate.plusWeeks(1);
case "MONTHLY" -> candidate.plusMonths(1).withDayOfMonth(1);
default -> candidate.plusDays(1);
};
}
throw new IllegalArgumentException("No future occurrence.");
}
catch(Exception e)
{
throw new QBadRequestException("Invalid schedule: choose a valid frequency/day, local time, time zone or Quartz cron.");
}
}
/*******************************************************************************
** Month days are one-based and never silently wrap into another month.
*******************************************************************************/
private static int requireDay(Integer day)
{
if(day == null || day < 1 || day > 31)
{
throw new IllegalArgumentException("Monthly day must be 1 to 31.");
}
return (day);
}
}
@@ -20,9 +20,7 @@
package com.kingsrook.qbits.dataanalysis.stats;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/*******************************************************************************
@@ -130,25 +128,53 @@ public final class Statistics
{
return (unavailable(test, "invalid counts"));
}
if(Arrays.stream(row).sum() < 2)
if(homogeneity && Arrays.stream(row).sum() < 2)
{
return (unavailable(test, "too few records"));
}
}
double[][] table = poolColumns(observed);
if(!homogeneity)
////////////////////////////////////////////////////////////////////////////////
// pool the smallest category into Other until every expected count is >= 5. //
// homogeneity compares fixed samples (rows), so only columns are pooled. //
////////////////////////////////////////////////////////////////////////////////
double[][] table = Arrays.stream(observed).map(double[]::clone).toArray(double[][]::new);
boolean rowOther = false;
boolean columnOther = false;
while(!validExpected(table))
{
table = transpose(poolColumns(transpose(table)));
double[][] rowFirst = poolColumns(transpose(poolColumns(transpose(observed))));
if(validExpected(rowFirst) && (!validExpected(table) || rowFirst.length * rowFirst[0].length > table.length * table[0].length))
double[] rows = Arrays.stream(table).mapToDouble(r -> Arrays.stream(r).sum()).toArray();
double[] cols = Arrays.stream(transpose(table)).mapToDouble(r -> Arrays.stream(r).sum()).toArray();
int smallestRow = homogeneity ? -1 : smallestIndex(rows, rowOther);
int smallestCol = smallestIndex(cols, columnOther);
boolean poolRow = smallestRow >= 0 && (smallestCol < 0 || rows[smallestRow] <= cols[smallestCol]);
if(poolRow)
{
table = rowFirst;
table = transpose(poolInto(transpose(table), smallestRow, rowOther));
rowOther = true;
}
else if(smallestCol >= 0)
{
table = poolInto(table, smallestCol, columnOther);
columnOther = true;
}
else
{
break;
}
if(table.length < 2 || table[0].length < 2)
{
break;
}
}
if(table.length < 2 || table[0].length < 2)
{
return (unavailable(test, "too few categories after merging expected counts below 5 into Other"));
}
if(!validExpected(table))
{
return (unavailable(test, homogeneity ? "too few records" : "expected counts below 5 after merging into Other"));
}
double[] rows = Arrays.stream(table).mapToDouble(r -> Arrays.stream(r).sum()).toArray();
double[] cols = Arrays.stream(transpose(table)).mapToDouble(r -> Arrays.stream(r).sum()).toArray();
double total = Arrays.stream(rows).sum();
@@ -158,10 +184,6 @@ public final class Statistics
for(int j = 0; j < cols.length; j++)
{
double expected = rows[i] * cols[j] / total;
if(expected < 5)
{
return (unavailable(test, "expected counts below 5 after merging into Other"));
}
statistic += Math.pow(table[i][j] - expected, 2) / expected;
}
}
@@ -171,52 +193,75 @@ public final class Statistics
return (new Result(test, statistic, df, p, true, sentence(p), v));
}
/*******************************************************************************
** Rare rows must not force every column into Other when row pooling alone
** would suffice. Prefer the valid pooling order retaining more categories.
** Every expected count is at least five (and the table is at least 2 by 2).
*******************************************************************************/
private static boolean validExpected(double[][] table)
{
if(table.length < 2 || table[0].length < 2)
{
return (false);
}
double[] rows = Arrays.stream(table).mapToDouble(r -> Arrays.stream(r).sum()).toArray();
double[] columns = Arrays.stream(transpose(table)).mapToDouble(r -> Arrays.stream(r).sum()).toArray();
return (rows.length >= 2 && columns.length >= 2
&& Arrays.stream(rows).min().orElse(0) * Arrays.stream(columns).min().orElse(0) / Arrays.stream(rows).sum() >= 5);
double total = Arrays.stream(rows).sum();
return (total > 0 && Arrays.stream(rows).min().orElse(0) * Arrays.stream(columns).min().orElse(0) / total >= 5);
}
/*******************************************************************************
** Pool all categories with any expected count below five into one final column.
** The index of the smallest category, not counting a trailing Other (which
** only grows), or -1 when there is nothing left to pool.
*******************************************************************************/
private static double[][] poolColumns(double[][] table)
private static int smallestIndex(double[] totals, boolean lastIsOther)
{
double[] rows = Arrays.stream(table).mapToDouble(r -> Arrays.stream(r).sum()).toArray();
double total = Arrays.stream(rows).sum();
List<Integer> kept = new ArrayList<>();
List<Integer> other = new ArrayList<>();
for(int j = 0; j < table[0].length; j++)
int limit = lastIsOther ? totals.length - 1 : totals.length;
if(limit <= (lastIsOther ? 0 : 1))
{
double sum = 0;
for(double[] row : table)
{
sum += row[j];
}
double column = sum;
(Arrays.stream(rows).anyMatch(r -> r * column / total < 5) ? other : kept).add(j);
return (-1);
}
double[][] result = new double[rows.length][kept.size() + (other.isEmpty() ? 0 : 1)];
for(int i = 0; i < rows.length; i++)
int smallest = 0;
for(int i = 1; i < limit; i++)
{
for(int j = 0; j < kept.size(); j++)
if(totals[i] < totals[smallest])
{
result[i][j] = table[i][kept.get(j)];
smallest = i;
}
for(int j : other)
}
return (smallest);
}
/*******************************************************************************
** Move one column into a trailing Other column (creating it if needed).
*******************************************************************************/
private static double[][] poolInto(double[][] table, int column, boolean hasOther)
{
int columns = table[0].length;
int width = hasOther ? columns - 1 : columns;
double[][] result = new double[table.length][width];
for(int i = 0; i < table.length; i++)
{
int target = 0;
for(int j = 0; j < columns; j++)
{
result[i][kept.size()] += table[i][j];
if(j == column || (hasOther && j == columns - 1))
{
continue;
}
result[i][target++] = table[i][j];
}
result[i][width - 1] = table[i][column] + (hasOther ? table[i][columns - 1] : 0);
}
return (result);
}
/*******************************************************************************
** Transposition lets independence apply the same pooling rule to both axes.
*******************************************************************************/
@@ -0,0 +1,43 @@
bundle: qbit-data-analysis
version: 1
layer: QBIT
# Help for the Analyses app and every data_analysis_* table, in the QQQ help-bundle
# format (qqq#1198). Each table entry says what the table is for and how its rows get
# created, so an empty table explains itself. DataAnalysisHelp applies it until the core
# HelpBundles registry ships in a QQQ release (STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8)).
# Summaries stay under 120 characters (DataAnalysisTablesGuardrailTest).
entries:
- key: app:dataAnalysis;slot:help
summary: "Profile, pivot, compare and watch any table, and tune how analysis behaves per table."
content: |
The Setup card on the home lists each part, its state and the next step. Analysis works on every analyzable table from the start with built-in limits, so nothing is required. Open Analysis Settings and run Create rows for analyzable tables to see those limits as rows you can edit per table.
- key: table:data_analysis_table_config;slot:help
summary: "Per-table analysis limits: on or off, row limits, sampling, smallest group, cache minutes and field lists."
content: |
**How rows get created:** run the table action Create rows for analyzable tables once. It adds one row per table holding the settings that already apply, so nothing changes until you edit a row. Or add a row with New for just one table. A table without a row uses the defaults from the configuration, so an empty table means every table is on with the defaults. Cache minutes controls how long an exact result is reused (0 turns caching off). Smallest group hides groups with fewer records.
- key: table:data_analysis_saved;slot:help
summary: "Analyses people saved: the table, filter and view they want to come back to."
content: |
**How rows get created:** a person saves an analysis from the Analyze screen on any table. Each saved analysis belongs to its owner; stars, schedules and watches hang off it. Empty means nobody has saved one yet.
- key: table:data_analysis_saved_star;slot:help
summary: "Which saved analyses each person starred."
content: |
**How rows get created:** a person stars a saved analysis, and the row is theirs. Starred analyses show on the Analyses home. Empty means nothing is starred yet.
- key: table:data_analysis_schedule;slot:help
summary: "Schedules that run a saved analysis daily, weekly or monthly and send the result."
content: |
**How rows get created:** a person adds a schedule on a saved analysis (the Schedules list on the saved analysis). Schedules run only when the host configures a scheduler for the qbit. Empty means no analysis is scheduled.
- key: table:data_analysis_watch;slot:help
summary: "Watches that alert when a saved analysis spikes, drops, goes out of range or gets a new category."
content: |
**How rows get created:** a person adds a watch on a saved analysis (the Watches list on the saved analysis). Enabled watches are evaluated every 15 minutes when a scheduler is configured; Evaluate now checks one by hand. A firing watch raises an alert. Empty means nothing is being watched.
- key: table:data_analysis_run;slot:help
summary: "Each exact analysis run: who, what, when and the stored result that is reused as a cache."
content: |
**How rows get created:** automatically when someone starts an exact analysis. A finished run is reused for the table's cache minutes (Analysis Settings). Empty means nobody has run an exact analysis yet.
@@ -781,6 +781,9 @@ components:
description: "Blanks."
nullable: true
type: "object"
DataAnalysisFileV1:
format: "binary"
type: "string"
DataAnalysisLimitsV1:
nullable: true
properties:
@@ -1154,6 +1157,111 @@ components:
nullable: true
type: "string"
type: "object"
DataAnalysisSavedEntryV1:
properties:
id:
type: "integer"
name:
type: "string"
kind:
type: "string"
tableName:
type: "string"
tableLabel:
type: "string"
owner:
type: "string"
updated:
format: "date-time"
nullable: true
type: "string"
shared:
type: "boolean"
starred:
type: "boolean"
type: "object"
DataAnalysisSavedListV1:
properties:
analyses:
items:
$ref: "#/components/schemas/DataAnalysisSavedEntryV1"
type: "array"
type: "object"
DataAnalysisSavedRequestV1:
properties:
kind:
enum:
- "COLUMN_PROFILE"
- "TABLE_PROFILE"
- "PIVOT"
- "COMPARE"
- "CORRELATION"
- "CROSSTAB"
- "SHIFT"
type: "string"
tableName:
description: "Source QQQ table name; required for ad hoc export, supplied\
\ by the path for SQL."
type: "string"
request:
anyOf:
- $ref: "#/components/schemas/DataAnalysisColumnProfileRequestV1"
- $ref: "#/components/schemas/DataAnalysisTableProfileRequestV1"
- $ref: "#/components/schemas/DataAnalysisPivotRequestV1"
- $ref: "#/components/schemas/DataAnalysisCompareRequestV1"
- $ref: "#/components/schemas/DataAnalysisCorrelationRequestV1"
- $ref: "#/components/schemas/DataAnalysisCrosstabRequestV1"
- $ref: "#/components/schemas/DataAnalysisShiftRequestV1"
description: "Typed request matching kind. Field names, filters and dimensions\
\ use the existing analysis request contracts."
format:
description: "Required for export; ignored for SQL."
enum:
- "csv"
- "xlsx"
type: "string"
required:
- "kind"
- "request"
type: "object"
DataAnalysisSavedResultV1:
properties:
kind:
enum:
- "COLUMN_PROFILE"
- "TABLE_PROFILE"
- "PIVOT"
- "COMPARE"
- "CORRELATION"
- "CROSSTAB"
- "SHIFT"
type: "string"
warnings:
items:
type: "string"
type: "array"
columnProfile:
$ref: "#/components/schemas/DataAnalysisColumnProfileV1"
nullable: true
compare:
$ref: "#/components/schemas/DataAnalysisCompareV1"
nullable: true
correlation:
$ref: "#/components/schemas/DataAnalysisCorrelationV1"
nullable: true
crosstab:
$ref: "#/components/schemas/DataAnalysisCrosstabV1"
nullable: true
pivot:
$ref: "#/components/schemas/DataAnalysisPivotV1"
nullable: true
shift:
$ref: "#/components/schemas/DataAnalysisShiftV1"
nullable: true
tableProfile:
$ref: "#/components/schemas/DataAnalysisTableProfileV1"
nullable: true
type: "object"
DataAnalysisScatterRequestV1:
properties:
tableName:
@@ -1329,6 +1437,21 @@ components:
nullable: true
type: "number"
type: "object"
DataAnalysisSqlV1:
properties:
available:
type: "boolean"
note:
type: "string"
sql:
nullable: true
type: "string"
type: "object"
DataAnalysisStarV1:
properties:
starred:
type: "boolean"
type: "object"
DataAnalysisStartRunRequestV1:
additionalProperties: false
properties:
@@ -1642,9 +1765,181 @@ info:
description: "Profiles, pivots, comparisons, distribution shifts, correlations,\
\ scatter samples, crosstabs and anomaly watch previews as the signed-in user."
title: "QQQ Middleware API extension: data-analysis"
version: "1.3.0"
version: "1.4.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/data-analysis/saved/{id}/run:
post:
description: "Re-run a visible saved analysis as the signed-in viewer. Requires\
\ saved READ and source READ/Analyze. Drops unavailable fields with a warning\
\ and returns the result typed by kind; inaccessible required axes or predicates\
\ produce no result."
parameters:
- description: "Owner-scoped analysis run or watch id."
in: "path"
name: "id"
required: true
schema:
type: "string"
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/DataAnalysisSavedResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Run a saved analysis"
/qqq/v1/qbit/data-analysis/saved:
get:
description: "List core-visible saved analyses with name, kind, source table\
\ label, owner, shared state, this viewer's starred flag and last update."
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/DataAnalysisSavedListV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "List visible saved analyses"
/qqq/v1/qbit/data-analysis/saved/{id}/star:
delete:
description: "Remove the signed-in user's star on a visible saved analysis.\
\ Idempotent; uses standard saved delete permission and owner locks."
parameters:
- description: "Owner-scoped analysis run or watch id."
in: "path"
name: "id"
required: true
schema:
type: "string"
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/DataAnalysisStarV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Unstar a saved analysis"
put:
description: "Star a visible saved analysis for the signed-in user. Idempotent;\
\ uses standard saved insert permission and the personal star record lock."
parameters:
- description: "Owner-scoped analysis run or watch id."
in: "path"
name: "id"
required: true
schema:
type: "string"
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/DataAnalysisStarV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Star a saved analysis"
/qqq/v1/qbit/data-analysis/tables/{table}/sql:
post:
description: "Execute a typed analysis as the viewer and capture the main RDBMS\
\ aggregate SQL from core QueryStat. Requires installed SQL Workbench and\
\ its access permission. Non-RDBMS backends report unavailable; SQL is for\
\ reading and record security remains enforced at runtime."
parameters:
- description: "QQQ table name to analyze."
in: "path"
name: "table"
required: true
schema:
type: "string"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/DataAnalysisSavedRequestV1"
required: true
responses:
200:
content:
application/json:
schema:
$ref: "#/components/schemas/DataAnalysisSqlV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Read an analysis aggregate SQL statement"
/qqq/v1/qbit/data-analysis/export:
post:
description: "Execute a typed analysis as the signed-in viewer and download\
\ its result table as CSV or XLSX. Requires source READ/Analyze and FULL fields;\
\ uses core report formats."
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/DataAnalysisSavedRequestV1"
required: true
responses:
200:
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema:
$ref: "#/components/schemas/DataAnalysisFileV1"
text/csv:
schema:
$ref: "#/components/schemas/DataAnalysisFileV1"
description: "Analysis result file"
security:
- sessionUuidCookie:
- "N/A"
summary: "Export an analysis result"
/qqq/v1/qbit/data-analysis/saved/{id}/export:
get:
description: "Re-run a visible saved analysis as the viewer, applying current\
\ source permissions and dropping unavailable fields, then download its result\
\ table as CSV or XLSX."
parameters:
- description: "Visible saved analysis id."
in: "path"
name: "id"
required: true
schema:
type: "integer"
- description: "Result file format."
in: "query"
name: "format"
required: true
schema:
enum:
- "csv"
- "xlsx"
type: "string"
responses:
200:
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema:
$ref: "#/components/schemas/DataAnalysisFileV1"
text/csv:
schema:
$ref: "#/components/schemas/DataAnalysisFileV1"
description: "Analysis result file"
security:
- sessionUuidCookie:
- "N/A"
summary: "Export a saved analysis"
/qqq/v1/qbit/data-analysis/tables/{table}/capabilities:
get:
description: "Discover whether the current user can analyze this table, readable\
@@ -119,8 +119,17 @@ class DataAnalysisApiTest extends EngineFixture
*******************************************************************************/
private JsonNode call(HttpMethod method, String path, Object body, int status) throws Exception
{
String savedId = path.startsWith("/saved/") ? path.split("/")[2] : null;
if(savedId != null)
{
path = path.replace("/saved/" + savedId + "/", "/saved/{id}/");
}
var request = new UiApiRequest().withMethod(method).withPath(DataAnalysisApi.PREFIX + path)
.withPathParam("table", "orders").withSessionUUID(caller).withUserTimezone("America/Chicago");
if(savedId != null)
{
request.withPathParam("id", savedId);
}
if(body != null)
{
request.withJsonBody(body);
@@ -173,7 +182,7 @@ class DataAnalysisApiTest extends EngineFixture
assertThat(denied.getStatus()).isEqualTo(403);
assertThat(run.path(operation.equals("COLUMN_PROFILE") ? "columnProfile" : "tableProfile").isObject()).isTrue();
}
assertThat(invoker.listOperations().stream().filter(o -> o.pathTemplate().startsWith(DataAnalysisApi.PREFIX)).toList()).hasSize(14);
assertThat(invoker.listOperations().stream().filter(o -> o.pathTemplate().startsWith(DataAnalysisApi.PREFIX)).toList()).hasSize(21);
var client = java.net.http.HttpClient.newHttpClient();
var response = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create("http://127.0.0.1:" + server.getBoundPort() + "/qqq/v1/openapi.json")).build(), java.net.http.HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
@@ -355,7 +364,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.3.0");
assertThat(com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi.extension().getSemanticVersion()).isEqualTo("1.4.0");
}
/*******************************************************************************
@@ -452,4 +461,56 @@ class DataAnalysisApiTest extends EngineFixture
assertThat(invoker.invoke(request.withSessionUUID(caller).withPathParam("id", "99999")).getStatus()).isEqualTo(404);
assertThat(invoker.invoke(request.withPathParam("id", "invalid")).getStatus()).isEqualTo(400);
}
@Test
void savedOperationsAndFileResponsesOverHttp() throws Exception
{
var shared = TestSupport.insert(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.SAVED,
SavedAnalysesTest.definition("alice", true, "TABLE_PROFILE", Map.of("fields", List.of("amount", "secret"))));
var privateRow = TestSupport.insert(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.SAVED,
SavedAnalysesTest.definition("alice", false, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
start();
caller = "watch-saved-viewer";
String path = "/saved/" + shared.getValue("id");
var result = call(HttpMethod.POST, path + "/run", null, 200);
assertThat(result.path("kind").asText()).isEqualTo("TABLE_PROFILE");
assertThat(result.path("warnings").toString()).contains("Some fields are not available to you");
assertThat(result.path("tableProfile").path("fields").size()).isEqualTo(1);
call(HttpMethod.POST, "/saved/" + privateRow.getValue("id") + "/run", null, 404);
call(HttpMethod.POST, "/saved/invalid/run", null, 400);
assertThat(call(HttpMethod.PUT, path + "/star", null, 200).path("starred").asBoolean()).isTrue();
call(HttpMethod.PUT, path + "/star", null, 200);
assertThat(call(HttpMethod.GET, "/saved", null, 200).path("analyses").get(0).path("starred").asBoolean()).isTrue();
assertThat(call(HttpMethod.DELETE, path + "/star", null, 200).path("starred").asBoolean()).isFalse();
assertThat(call(HttpMethod.POST, "/tables/{table}/sql", Map.of("kind", "TABLE_PROFILE", "request", Map.of("fields", List.of("amount"))), 200).path("available").asBoolean()).isFalse();
var client = java.net.http.HttpClient.newHttpClient();
for(String format : List.of("csv", "xlsx"))
{
String url = "http://127.0.0.1:" + server.getBoundPort() + "/qqq/v1" + DataAnalysisApi.PREFIX + path + "/export?format=" + format;
var response = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create(url)).header("Cookie", "sessionUUID=" + caller).GET().build(), java.net.http.HttpResponse.BodyHandlers.ofByteArray());
assertThat(response.statusCode()).as(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8)).isEqualTo(200);
assertThat(response.headers().firstValue("Content-Disposition").orElse("")).contains("analysis." + format);
if(format.equals("xlsx"))
{
try(var workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(new java.io.ByteArrayInputStream(response.body())))
{
assertThat(workbook.getSheetAt(0).getLastRowNum()).isPositive();
}
}
else
{
assertThat(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8)).contains("Completeness");
}
}
var export = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath(DataAnalysisApi.PREFIX + "/export").withSessionUUID(caller)
.withJsonBody(Map.of("kind", "COLUMN_PROFILE", "tableName", "orders", "request", Map.of("fieldName", "amount"), "format", "csv")));
assertThat(export.getStatus()).as(export.getBody()).isEqualTo(200);
assertThat(export.getBody()).contains("Counts");
call(HttpMethod.POST, "/export", Map.of("kind", "TABLE_PROFILE", "tableName", "orders", "request", Map.of(), "format", "pdf"), 400);
caller = "denied";
call(HttpMethod.POST, path + "/run", null, 403);
var operations = invoker.listOperations().stream().filter(o -> o.pathTemplate().startsWith(DataAnalysisApi.PREFIX)).toList();
assertThat(operations).hasSize(21);
assertThat(operations.stream().map(Object::toString).toList().toString()).contains("/saved/{id}/run", "/saved/{id}/star", "/saved/{id}/export", "/tables/{table}/sql", "/export");
}
}
@@ -20,131 +20,95 @@
package com.kingsrook.qbits.dataanalysis;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Real home counts, empty states and permission-scoped results.
*******************************************************************************/
class DataAnalysisHomeTest
{
/*******************************************************************************
** Release the thread-local context.
*******************************************************************************/
@AfterEach
void cleanup()
{
QContext.clear();
}
/*******************************************************************************
** Empty counts explain why; missing permissions are not represented as zero.
*******************************************************************************/
@Test
void emptyAndDenied() throws Exception
void standardWidgetsWithEmptyAndDeniedStates() throws Exception
{
QInstance instance = TestSupport.instance();
var instance = TestSupport.instance();
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.read", "data_analysis.run.read"));
String html = DataAnalysisHome.render(Instant.now());
assertThat(html).contains("Mine: 0", "Starred by me: 0", "No saved analyses yet",
"No watches yet.", "No analysis runs yet.", "View all", "Open any table and choose Analyze on a column header to explore its data.", "<h3>");
var widget = new DataAnalysisHome().render(new RenderWidgetInput()).getWidgetData();
assertThat(widget).isInstanceOf(com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML.class);
assertThat(((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) widget).getType()).isEqualTo("html");
assertThat(html).contains("&middot;").doesNotContain("Â", "later", "phase", "<table", "color:", "width:360", "width: 360");
assertThat(html.chars().allMatch(c -> c < 128)).isTrue();
QContext.init(instance, TestSupport.user("alice"));
html = DataAnalysisHome.render(Instant.now());
assertThat(html).contains("you need data_analysis.saved.read", "you need data_analysis.watch.read").doesNotContain("Mine: 0", "Complete: 0");
QContext.init(instance, new QSession());
assertThat(DataAnalysisHome.render(Instant.now())).contains("Sign in to see your saved analyses");
}
/*******************************************************************************
** Different owners, shared flags, stars, disabled watches and date boundaries.
*******************************************************************************/
@Test
void countsUseCurrentUserAndRealRows() throws Exception
{
QInstance instance = TestSupport.instance();
Instant now = Instant.parse("2026-10-13T12:00:00Z");
QRecord mine = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", true));
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false));
QRecord bob = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", true));
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", mine.getValue("id")).withValue("user_id", "alice"));
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", bob.getValue("id")).withValue("user_id", "alice"));
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", mine.getValue("id")).withValue("user_id", "bob"));
TestSupport.insert(DataAnalysisTables.WATCH, WatchDetectorsTest.definition("SPIKE", "COUNT", null).withValue("enabled", true).withValue("state", "FIRING"));
TestSupport.insert(DataAnalysisTables.WATCH, WatchDetectorsTest.definition("DROP", "COUNT", null).withValue("enabled", true));
TestSupport.insert(DataAnalysisTables.WATCH, WatchDetectorsTest.definition("DROP", "COUNT", null).withValue("enabled", false));
run("alice", "COMPLETE", now);
run("alice", "COMPLETE", now.minus(7, ChronoUnit.DAYS));
run("alice", "FAILED", now.minus(1, ChronoUnit.DAYS));
run("alice", "COMPLETE", now.minus(7, ChronoUnit.DAYS).minusSeconds(1));
run("alice", "FAILED", now.plusSeconds(1));
run("alice", "RUNNING", now);
run("bob", "FAILED", now);
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.read", "data_analysis.run.read"));
String html = DataAnalysisHome.render(now);
assertThat(html).contains("Mine: 2", "Shared (visible to you): 1", "Starred by me: 1", "Enabled: 2", "Firing: 1", "Recent analysis runs", "Complete", "Failed", "Running", "Not finished")
.doesNotContain("No saved analyses", "No watches yet", "No analysis runs yet");
QContext.init(instance, TestSupport.user("bob").withPermissions("data_analysis.saved.read", "data_analysis.run.read"));
html = DataAnalysisHome.render(now);
assertThat(html).contains("Mine: 1", "Shared (visible to you): 1", "Starred by me: 0", "Failed", "you need data_analysis.watch.read");
}
/*******************************************************************************
** Latest five remain owner-scoped even with administrator bypass and escape HTML.
*******************************************************************************/
@Test
void recentRunDetailsAndLimit() throws Exception
{
QInstance instance = TestSupport.instance();
instance.getTable(DataAnalysisTables.SAVED).withLabel("Saved <analysis> & review");
Instant now = Instant.parse("2026-10-13T23:10:00Z");
for(int i = 0; i < 7; i++)
var counts = DataAnalysisHome.statistics(Instant.now());
assertThat(counts.getStatisticsGroupData()).flatExtracting(g -> g.getStatisticList()).allSatisfy(s -> assertThat(s.getValue()).isZero());
for(var widget : DataAnalysisHome.widgets())
{
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", DataAnalysisTables.SAVED)
.withValue("operation", "COLUMN_PROFILE").withValue("user_id", "admin").withValue("status", "COMPLETE")
.withValue("started_at", now.minusSeconds(i * 60L)).withValue("finished_at", now.minusSeconds(i * 60L)));
assertThat(widget.getType()).isIn("multiStatistics", "table");
var data = new DataAnalysisHome().render(new RenderWidgetInput().withWidgetMetaData(widget)).getWidgetData();
if(data instanceof TableData table && DataAnalysisHome.SETUP.equals(widget.getName()))
{
//////////////////////////////////////////////////////////////////////////////
// the Setup card is never empty: it says what is set up and what is next //
//////////////////////////////////////////////////////////////////////////////
assertThat(table.getColumns()).hasSizeLessThanOrEqualTo(4);
assertThat(table.getRows()).hasSize(5);
}
else if(data instanceof TableData table)
{
assertThat(table.getColumns()).hasSizeLessThanOrEqualTo(4);
assertThat(table.getRows()).isEmpty();
assertThat(table.getNoRowsFoundHTML()).startsWith("No ");
}
}
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", "private_bob_table")
.withValue("operation", "TABLE_PROFILE").withValue("user_id", "bob").withValue("status", "FAILED").withValue("started_at", now));
QContext.init(instance, TestSupport.admin().withValue(QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago"));
String html = DataAnalysisHome.render(now);
assertThat(html).contains("Saved &lt;analysis&gt; &amp; review", "Column Profile", "2026-10-13 18:10", "2026-10-13 18:06", "<ul", "<dl")
.doesNotContain("private_bob_table", "2026-10-13 18:05", "<analysis>");
assertThat(html.split("<li>", -1)).hasSize(6);
assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "/1\"");
assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "\">View all</a>");
QContext.init(instance, TestSupport.user("alice"));
assertThat(DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData()).allSatisfy(g ->
{
assertThat(g.getStatisticList()).isEmpty();
assertThat(g.getSubheader()).contains("Unavailable");
});
assertThat(((TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, Instant.now())).getNoRowsFoundHTML()).contains("Unavailable");
}
@Test
void countsRowsLinksAndDateBoundaries() throws Exception
{
var instance = TestSupport.instance();
Instant now = Instant.parse("2026-10-13T12:00:00Z");
var mine = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false).withValue("name", "<script>& profile"));
var shared = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", true));
var hidden = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", false));
for(var saved : List.of(mine, shared, hidden))
{
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", saved.getValue("id")).withValue("user_id", "alice"));
}
for(Instant start : List.of(now, now.minus(7, ChronoUnit.DAYS), now.minus(7, ChronoUnit.DAYS).minusSeconds(1), now.plusSeconds(1)))
{
run("alice", start);
}
run("bob", now);
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.run.read"));
var counts = DataAnalysisHome.statistics(now).getStatisticsGroupData();
assertThat(counts.getFirst().getStatisticList()).extracting(s -> s.getValue()).containsExactly(1, 1, 2);
assertThat(counts.getLast().getStatisticList().getFirst().getValue()).isEqualTo(2);
TableData starred = (TableData) DataAnalysisHome.render(DataAnalysisHome.STARRED, now);
assertThat(starred.getRows()).hasSize(2);
assertThat(starred.getRows().toString()).contains("&lt;script&gt;&amp; profile", instance.getTablePath(DataAnalysisTables.SAVED)).doesNotContain("<script>");
TableData recent = (TableData) DataAnalysisHome.render(DataAnalysisHome.RECENT, now);
assertThat(recent.getRows()).hasSize(3);
assertThat(recent.getLinkURL()).isEqualTo(instance.getTablePath(DataAnalysisTables.RUN));
}
/*******************************************************************************
** Insert a real run row with an explicit owner and start time.
*******************************************************************************/
private static void run(String user, String status, Instant started) throws Exception
private static void run(String user, Instant started) throws Exception
{
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", "orders").withValue("operation", "COLUMN_PROFILE")
.withValue("user_id", user).withValue("status", status).withValue("started_at", started));
.withValue("user_id", user).withValue("status", "COMPLETE").withValue("started_at", started));
}
}
@@ -69,7 +69,7 @@ class DataAnalysisMetadataTest
assertThat(instance.getTables()).containsOnlyKeys(DataAnalysisTables.ALL);
assertThat(instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("data_analysis_"))).hasSize(16);
assertThat(instance.getJoins()).hasSize(3);
assertThat(instance.getWidgets()).hasSize(4).containsKey(DataAnalysisHome.HOME_WIDGET);
assertThat(instance.getWidgets()).hasSize(9).containsKey(DataAnalysisHome.HOME_WIDGET);
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getProcesses()).isNullOrEmpty();
assertThat(instance.getApp("dataAnalysis").getLabel()).isEqualTo("Analyses");
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getTables()).containsExactlyElementsOf(DataAnalysisTables.NAV_TABLES);
@@ -90,7 +90,7 @@ class DataAnalysisMetadataTest
assertThat(lock.getSecurityKeyType()).isEqualTo("data_analysis_user");
assertThat(lock.getFieldName()).isEqualTo(name.equals(DataAnalysisTables.SAVED) ? "owner_user_id" : "user_id");
assertThat(lock.getNullValueBehavior()).isEqualTo(RecordSecurityLock.NullValueBehavior.DENY);
assertThat(lock.getLockScope()).isEqualTo(RecordSecurityLock.LockScope.READ_AND_WRITE);
assertThat(lock.getLockScope()).isEqualTo(name.equals(DataAnalysisTables.SAVED) ? RecordSecurityLock.LockScope.WRITE : RecordSecurityLock.LockScope.READ_AND_WRITE);
}
assertThat(instance.getSecurityKeyType("data_analysis_user").getAllAccessKeyName()).isEqualTo("data_analysis.admin");
assertThat(PermissionsHelper.getAllAvailablePermissionNames(instance)).contains("data_analysis.settings.read", "data_analysis.settings.edit", "data_analysis.run.read", "data_analysis.run.delete", "data_analysis.saved.read", "data_analysis.saved.insert",
@@ -205,7 +205,8 @@ class DataAnalysisMetadataTest
assertThat(ddl).contains("id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", "create_date TIMESTAMP WITHOUT TIME ZONE",
"modify_date TIMESTAMP WITHOUT TIME ZONE", "rows_scanned BIGINT", "result_json TEXT", "shared BOOLEAN", "day INTEGER",
"owner_user_id VARCHAR(250) NOT NULL", "date_field VARCHAR(250) NOT NULL", "last_value NUMERIC", "last_message TEXT", "CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_table_config_1 ON data_analysis_table_config (table_name)",
"CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_saved_star_1 ON data_analysis_saved_star (saved_id, user_id)");
"CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_saved_star_1 ON data_analysis_saved_star (saved_id, user_id)",
"CHECK (shared = TRUE OR read_owner_user_id IS NOT NULL)");
assertThat(ddl).endsWith(";\n");
}
}
@@ -67,9 +67,13 @@ class DataAnalysisPossibleValuesTest
void watchSavedAnalysisDependsOnTable() throws Exception
{
QInstance instance = TestSupport.instance();
QRecord orders = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false));
for(String name : List.of("orders", "customers"))
{
instance.addTable(new QTableMetaData().withName(name).withField(new QFieldMetaData("table_name", QFieldType.STRING)));
}
QRecord orders = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false).withValue("table_name", "orders"));
QRecord customers = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false).withValue("table_name", "customers"));
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", true));
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", false).withValue("table_name", "orders"));
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.edit"));
QFieldMetaData field = instance.getTable(DataAnalysisTables.WATCH).getField("saved_id");
assertThat(search(field, Map.of("table_name", "orders"))).extracting(value -> (Object) value.getId()).containsExactly(orders.getValueInteger("id"));
@@ -125,7 +125,7 @@ class DataAnalysisSecurityTest
}
/*******************************************************************************
** Saved, star and run locks hide others even when the shared flag is true.
** Shared saved rows are readable; private stars, schedules and runs remain owner-locked.
*******************************************************************************/
@Test
void ownerIsolation() throws Exception
@@ -142,12 +142,12 @@ class DataAnalysisSecurityTest
TestSupport.insert(DataAnalysisTables.SCHEDULE, new QRecord().withValue("saved_id", mine.getValue("id")));
TestSupport.insert(DataAnalysisTables.SCHEDULE, new QRecord().withValue("saved_id", other.getValue("id")));
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.run.read"));
assertThat(query(DataAnalysisTables.SAVED)).extracting(r -> r.getValueString("owner_user_id")).containsExactly("alice");
assertThat(query(DataAnalysisTables.SAVED)).extracting(r -> r.getValueString("owner_user_id")).containsExactly("alice", "bob");
assertThat(query(DataAnalysisTables.SAVED_STAR)).extracting(r -> r.getValueString("user_id")).containsExactly("alice");
assertThat(query(DataAnalysisTables.RUN)).extracting(r -> r.getValueString("user_id")).containsExactly("alice");
assertThat(query(DataAnalysisTables.SCHEDULE)).extracting(r -> r.getValue("saved_id")).containsExactly(mine.getValue("id"));
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.admin"));
assertThat(query(DataAnalysisTables.SAVED)).hasSize(1);
assertThat(query(DataAnalysisTables.SAVED)).hasSize(2);
QContext.getQSession().withSecurityKeyValue("data_analysis.admin", true);
assertThat(query(DataAnalysisTables.SAVED)).hasSize(2);
}
@@ -0,0 +1,238 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.dataanalysis.help.DataAnalysisHelp;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every data_analysis_* table:
** - runtime code (not just its model, metadata or home screen) reads or writes
** it, so no table exists only on paper;
** - it is either seeded (analysis settings, by the Create rows action, from the
** configuration at run time) or listed here with why it starts empty;
** - it has bundle help that says what it is for and how its rows get created.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class DataAnalysisTablesGuardrailTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "dataanalysis");
/*******************************************************************************
** Seeded from the code configuration by "Create rows for analyzable tables".
*******************************************************************************/
static final List<String> SEEDED = List.of(DataAnalysisTables.TABLE_CONFIG);
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.of(
DataAnalysisTables.SAVED, "user content: a person saves an analysis",
DataAnalysisTables.SAVED_STAR, "user content: a person stars a saved analysis",
DataAnalysisTables.SCHEDULE, "user content: a person schedules a saved analysis",
DataAnalysisTables.WATCH, "user content: a person adds a watch to a saved analysis",
DataAnalysisTables.RUN, "runtime: someone starts an exact analysis (it is also the result cache)");
private QInstance instance;
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
instance = TestSupport.instance();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
Map<String, String> constantByTable = constantByTable();
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")) && !p.startsWith(MAIN.resolve("help"))
&& !p.getFileName().toString().equals("DataAnalysisSchema.java"))
.map(DataAnalysisTablesGuardrailTest::read).toList();
}
for(String table : analysisTables())
{
String constant = constantByTable.get(table);
assertThat(constant).as("constant for " + table).isNotNull();
Pattern use = Pattern.compile("DataAnalysisTables\\." + constant + "\\b");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(table + " (" + constant + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
for(String table : analysisTables())
{
boolean seeded = SEEDED.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
}
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
///////////////////////////////////////////////////////////////////////////////
// the seeded table has a real action that writes its rows, named in its help //
///////////////////////////////////////////////////////////////////////////////
assertThat(instance.getProcess(TableConfigDefaultsProcess.NAME).getTableName()).isEqualTo(DataAnalysisTables.TABLE_CONFIG);
assertThat(instance.getProcess(TableConfigDefaultsProcess.NAME).getMinInputRecords()).isEqualTo(0);
assertThat(help(DataAnalysisTables.TABLE_CONFIG).getFirst().getContent()).contains(instance.getProcess(TableConfigDefaultsProcess.NAME).getLabel());
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
assertThat(DataAnalysisHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String table : analysisTables())
{
List<QHelpContent> help = help(table);
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.getFirst().getContent()).as(table + " help says how rows get created").contains("How rows get created");
}
assertThat(instance.getApp("dataAnalysis").getHelpContent()).containsKey(DataAnalysisHelp.SLOT);
assertThat(DataAnalysisHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null,
"help names only real tables");
}
/*******************************************************************************
** A host's own help on a table is kept.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden() throws Exception
{
instance.getTable(DataAnalysisTables.SAVED).getHelpContent().put(DataAnalysisHelp.SLOT, List.of(new QHelpContent().withContentAsMarkdown("host text")));
DataAnalysisHelp.apply(instance, new DataAnalysisQBitConfig());
assertThat(help(DataAnalysisTables.SAVED).getFirst().getContent()).isEqualTo("host text");
}
/*******************************************************************************
**
*******************************************************************************/
private List<QHelpContent> help(String table)
{
return (instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(DataAnalysisHelp.SLOT));
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> analysisTables()
{
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("data_analysis_")).sorted().toList();
assertThat(tables).containsExactlyInAnyOrderElementsOf(DataAnalysisTables.ALL);
return (tables);
}
/*******************************************************************************
** DataAnalysisTables declares X = "data_analysis_x".
*******************************************************************************/
private static Map<String, String> constantByTable() throws Exception
{
Map<String, String> rs = new HashMap<>();
Matcher m = Pattern.compile("String\\s+([A-Z_]+)\\s*=\\s*\"(data_analysis_[a-z_]+)\"").matcher(read(MAIN.resolve("model").resolve("DataAnalysisTables.java")));
while(m.find())
{
rs.put(m.group(2), m.group(1));
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,212 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.app.SavedAnalysisWidget;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qbits.dataanalysis.saved.SqlHandoff;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SavedAnalysesTest extends EngineFixture
{
static QSession viewer(String id, int customer)
{
return (TestSupport.user(id).withPermissions("data_analysis.saved.read", "data_analysis.saved.insert", "data_analysis.saved.edit", "data_analysis.saved.delete",
"orders.read", "orders.analyze", "customer.read").withSecurityKeyValue("customer_scope", customer));
}
static QRecord definition(String owner, boolean shared, String kind, Map<String, Object> body)
{
return (TestSupport.saved(owner, shared).withValue("table_name", "orders").withValue("kind", kind).withValue("definition_json", JsonUtils.toJson(body)));
}
@Test
void sharedReadOwnerWriteAndFieldReduction() throws Exception
{
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, "TABLE_PROFILE", Map.of("fields", List.of("amount", "secret"))));
var privateRow = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", false, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
QContext.init(instance, viewer("bob", 2));
var api = new SavedAnalyses(service);
assertThat(api.list()).extracting(SavedAnalyses.Entry::id).contains(saved.getValue("id")).doesNotContain(privateRow.getValue("id"));
var result = api.run(saved.getValue("id"));
assertThat(result.kind()).isEqualTo("TABLE_PROFILE");
assertThat(result.warnings()).containsExactly(SavedDefinition.FIELD_WARNING);
assertThat(result.tableProfile().getFields()).extracting(f -> f.getField()).containsExactly("amount");
assertThat(((Number) result.tableProfile().getRowCount().getValue()).intValue()).isEqualTo(4);
assertThatThrownBy(() -> api.run(privateRow.getValue("id"))).isInstanceOf(QException.class);
var patch = new QRecord().withValue("id", saved.getValue("id")).withValue("name", "Stolen").withValue("read_owner_user_id", "bob");
assertThat(new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SAVED).withRecords(List.of(patch))).getRecords().getFirst().getErrors()).isNotEmpty();
var deleted = new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(
new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput(DataAnalysisTables.SAVED).withPrimaryKeys(List.of(saved.getValue("id"))));
assertThat(deleted.getDeletedRecordCount()).isZero();
QContext.init(instance, viewer("alice", 1));
var updated = new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SAVED).withRecords(List.of(new QRecord().withValue("id", saved.getValue("id")).withValue("shared", false)))).getRecords().getFirst();
assertThat(updated.getErrors()).isNullOrEmpty();
QContext.init(instance, viewer("bob", 2));
assertThat(api.list()).isEmpty();
}
/*******************************************************************************
** A UI insert without an owner belongs to the inserting user; naming someone
** else is still refused for a non-administrator.
*******************************************************************************/
@Test
void insertWithoutOwnerBelongsToTheCurrentUser() throws Exception
{
QContext.init(instance, viewer("bob", 2));
QRecord row = definition("bob", false, "COLUMN_PROFILE", Map.of("fieldName", "amount"));
row.removeValue("owner_user_id");
QRecord inserted = new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(row))).getRecords().getFirst();
assertThat(inserted.getErrors()).isEmpty();
assertThat(inserted.getValueString("owner_user_id")).isEqualTo("bob");
QRecord forged = definition("alice", false, "COLUMN_PROFILE", Map.of("fieldName", "amount"));
assertThat(new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(forged))).getRecords().getFirst().getErrors()).isNotEmpty();
}
@Test
void starsAreIdempotentAndPersonal() throws Exception
{
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
QContext.init(instance, viewer("bob", 2));
var api = new SavedAnalyses(service);
assertThat(api.star(saved.getValue("id"), true)).isTrue();
api.star(saved.getValue("id"), true);
assertThat(api.list().getFirst().starred()).isTrue();
QContext.init(instance, viewer("alice", 1));
assertThat(api.list().getFirst().starred()).isFalse();
QContext.init(instance, viewer("bob", 2));
assertThat(api.star(saved.getValue("id"), false)).isFalse();
api.star(saved.getValue("id"), false);
assertThat(api.list().getFirst().starred()).isFalse();
}
@Test
void malformedDefinitionsAndPartialEditsReturnPlainErrors() throws Exception
{
for(String json : List.of("not json", "[]", "{}", "{\"fieldName\":\"missing\"}", "{\"tableName\":\"other\",\"fieldName\":\"amount\"}"))
{
QRecord row = definition("admin", false, "COLUMN_PROFILE", Map.of()).withValue("definition_json", json);
var inserted = new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(row))).getRecords().getFirst();
assertThat(inserted.getErrors()).isNotEmpty();
}
assertThatThrownBy(() -> SavedDefinition.parse("UNKNOWN", "orders", Map.of())).hasMessageContaining("kind");
assertThatThrownBy(() -> SavedDefinition.parse("TABLE_PROFILE", "unknown", Map.of())).hasMessageContaining("table");
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("admin", false, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
var invalid = new QRecord().withValue("id", saved.getValue("id")).withValue("definition_json", "{\"fieldName\":\"nope\"}");
assertThat(new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SAVED).withRecords(List.of(invalid))).getRecords().getFirst().getErrors()).isNotEmpty();
assertThat(WatchRecords.get(DataAnalysisTables.SAVED, saved.getValue("id")).getValueString("definition_json")).contains("amount");
}
@Test
void allKindsRunAndExportRealTables() throws Exception
{
Map<String, Map<String, Object>> definitions = Map.of(
"COLUMN_PROFILE", Map.of("fieldName", "amount"),
"TABLE_PROFILE", Map.of("fields", List.of("amount", "status")),
"PIVOT", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT"))),
"COMPARE", Map.of("fields", List.of("amount", "status")),
"CORRELATION", Map.of("fields", List.of("amount", "quantity")),
"CROSSTAB", Map.of("rowField", "status", "columnField", "paid"),
"SHIFT", Map.of("field", "amount", "dateField", "created", "bucket", "DAY"));
for(var entry : definitions.entrySet())
{
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, entry.getKey(), entry.getValue()));
QContext.init(instance, viewer("alice", 1));
var result = new SavedAnalyses(service).run(saved.getValue("id"));
assertThat(result.kind()).isEqualTo(entry.getKey());
assertThat(AnalysisExport.table(result).rows()).as(entry.getKey()).isNotEmpty();
var csv = AnalysisExport.file(result, "csv");
assertThat(new String(csv.bytes(), StandardCharsets.UTF_8)).contains(AnalysisExport.table(result).columns().getFirst());
var xlsx = AnalysisExport.file(result, "xlsx");
try(var workbook = WorkbookFactory.create(new ByteArrayInputStream(xlsx.bytes())))
{
var sheet = workbook.getSheetAt(0);
assertThat(sheet.getLastRowNum()).isEqualTo(AnalysisExport.table(result).rows().size());
assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo(AnalysisExport.table(result).columns().getFirst());
assertThat(sheet.getRow(1).getCell(0).getStringCellValue()).isEqualTo(AnalysisExport.table(result).rows().getFirst().getFirst());
}
var widget = SavedAnalysisWidget.widget().withDefaultValue("savedId", saved.getValue("id"));
var chart = (ChartData) new SavedAnalysisWidget().render(new RenderWidgetInput().withWidgetMetaData(widget)).getWidgetData();
assertThat(chart.getChartData().getDatasets()).as(entry.getKey()).isNotEmpty();
assertThat(chart.getFooterHTML()).contains("View saved analysis");
QContext.init(instance, session());
}
assertThatThrownBy(() -> AnalysisExport.format("pdf")).hasMessageContaining("csv or xlsx");
}
@Test
void unavailableRequiredFieldsAndFiltersNeverBroadenResults() throws Exception
{
QContext.init(instance, viewer("alice", 1));
for(var definition : List.of(
SavedDefinition.parse("COLUMN_PROFILE", "orders", Map.of("fieldName", "secret")),
SavedDefinition.parse("CORRELATION", "orders", Map.of("fields", List.of("amount", "secret"))),
SavedDefinition.parse("TABLE_PROFILE", "orders", Map.of("fields", List.of("amount"), "filter", Map.of("criteria", List.of(Map.of("fieldName", "secret", "operator", "EQUALS", "values", List.of("x"))))))))
{
var result = definition.run(service, false);
assertThat(result.warnings()).contains(SavedDefinition.FIELD_WARNING);
assertThat(AnalysisExport.table(result).rows()).isEmpty();
assertThatThrownBy(() -> definition.run(service, true)).hasMessageContaining(SavedDefinition.FIELD_WARNING);
}
}
@Test
void savedWidgetUsesViewerAndMemoryHasNoSql() throws Exception
{
var saved = TestSupport.insert(DataAnalysisTables.SAVED, definition("alice", true, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
QContext.init(instance, viewer("bob", 2));
var empty = (ChartData) new SavedAnalysisWidget().render(new RenderWidgetInput().withWidgetMetaData(SavedAnalysisWidget.widget())).getWidgetData();
assertThat(empty.getDescription()).contains("Choose a saved analysis");
var metadata = SavedAnalysisWidget.widget().withDefaultValue("savedId", saved.getValue("id"));
var data = (ChartData) new SavedAnalysisWidget().render(new RenderWidgetInput().withWidgetMetaData(metadata)).getWidgetData();
assertThat(data.getChartData().getLabels()).isNotEmpty();
assertThat(data.getDescription()).contains("4");
assertThat(data.getFooterHTML()).contains("/" + saved.getValue("id"));
var definition = SavedDefinition.parse("COLUMN_PROFILE", "orders", Map.of("fieldName", "amount"));
assertThat(SqlHandoff.execute(definition, service).available()).isFalse();
instance.addApp(new QAppMetaData().withName("sqlWorkbench"));
QContext.getQSession().withPermission("sql_workbench.hasAccess");
assertThat(SqlHandoff.execute(definition, service).note()).contains("non-RDBMS");
}
}
@@ -0,0 +1,72 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qbits.dataanalysis.saved.SqlHandoff;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SavedSqlH2Test extends EngineFixture
{
@Override
protected RDBMSBackendMetaData database()
{
return (new RDBMSBackendMetaData().withVendor("h2").withUsername("sa").withPassword("")
.withJdbcUrl("jdbc:h2:mem:saved_" + UUID.randomUUID() + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE"));
}
@Test
void actualMainAggregateHasSecurityFiltersAndWorkbenchPermission() throws Exception
{
instance.addApp(new QAppMetaData().withName("sqlWorkbench"));
QContext.init(instance, SavedAnalysesTest.viewer("alice", 1));
var definition = SavedDefinition.parse("PIVOT", "orders", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT"))));
assertThat(SqlHandoff.execute(definition, service).available()).isFalse();
QContext.getQSession().withPermission("sql_workbench.hasAccess");
var result = SqlHandoff.execute(definition, service);
assertThat(result.available()).as(result.note()).isTrue();
assertThat(result.sql().toLowerCase()).contains("select", "count(", "group by", "customer", "status");
assertThat(result.note()).contains("Record security", "for reading");
}
@Test
void profileMainStatementIsTheMetricBatchAndCaptureDoesNotMutateMetadata() throws Exception
{
instance.addApp(new QAppMetaData().withName("sqlWorkbench"));
QContext.init(instance, SavedAnalysesTest.viewer("alice", 1).withPermission("sql_workbench.hasAccess"));
var before = java.util.Set.copyOf(instance.getTable("orders").getEnabledCapabilities());
var result = SqlHandoff.execute(SavedDefinition.parse("COLUMN_PROFILE", "orders", Map.of("fieldName", "amount")), service);
assertThat(result.available()).as(result.note()).isTrue();
assertThat(result.sql().toLowerCase()).contains("select", "amount", "customer").doesNotContain("group by");
assertThat(instance.getTable("orders").getEnabledCapabilities()).containsExactlyInAnyOrderElementsOf(before);
// A second request captures its own SQL rather than stale thread-local state.
var next = SqlHandoff.execute(SavedDefinition.parse("CORRELATION", "orders", Map.of("fields", List.of("amount", "quantity"))), service);
assertThat(next.available()).as(next.note()).isTrue();
assertThat(next.sql().toLowerCase()).contains("corr(");
}
}
@@ -0,0 +1,231 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.ScheduleActions;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner;
import com.kingsrook.qbits.dataanalysis.schedule.ScheduleTiming;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
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.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SchedulesTest extends EngineFixture
{
static final Instant NOW = Instant.parse("2026-10-13T12:00:00Z");
private ScheduleRunner runner;
public static class Mail implements MessagingProviderInterface
{
static final List<SendMessageInput> sent = new ArrayList<>();
static boolean fail;
@Override
public String getType()
{
return ("analysis-test-mail");
}
@Override
public SendMessageOutput sendMessage(SendMessageInput input) throws QException
{
if(fail)
{
throw new QException("Test mail failure");
}
sent.add(input);
return (new SendMessageOutput());
}
}
@BeforeEach
void deliverySetup() throws Exception
{
Mail.sent.clear();
Mail.fail = false;
WatchesTest.Sink.raised.clear();
QMessagingProviderDispatcher.registerMessagingProvider(new Mail());
instance.addMessagingProvider(new QMessagingProviderMetaData().withName("mail").withType("analysis-test-mail"));
AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new QCodeReference(WatchesTest.Sink.class));
config.withMessagingProviderName("mail").withFromAddress("analysis@example.test").withRecipientAddressSupplier(id -> id + "@example.test")
.withOwnerSessionSupplier(id ->
{
if(id.equals("missing"))
{
return (null);
}
QSession user = SavedAnalysesTest.viewer(id, id.equals("alice") ? 1 : 2);
if(id.equals("denied"))
{
user.getPermissions().remove("orders.analyze");
}
return (user);
});
runner = new ScheduleRunner(config, service);
}
QRecord schedule(String kind, Map<String, Object> definition, String format) throws Exception
{
var saved = TestSupport.insert(DataAnalysisTables.SAVED, SavedAnalysesTest.definition("alice", false, kind, definition).withValue("name", "<Orders>"));
var schedule = TestSupport.insert(DataAnalysisTables.SCHEDULE, new QRecord().withValue("saved_id", saved.getValue("id"))
.withValue("frequency", "DAILY").withValue("time", "09:00").withValue("time_zone", "America/Chicago")
.withValue("recipients", "[\"alice\",\"bob\",\"denied\",\"missing\"]").withValue("format", format).withValue("enabled", true));
new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SCHEDULE).withRecords(List.of(new QRecord().withValue("id", schedule.getValue("id")).withValue("next_run_at", NOW))));
return (schedule);
}
@Test
void calendarWeeklyMonthlyDstAndCron() throws Exception
{
QRecord row = new QRecord().withValue("frequency", "DAILY").withValue("time", "09:00").withValue("time_zone", "America/Chicago");
assertThat(ScheduleTiming.next(row, NOW)).isEqualTo(Instant.parse("2026-10-13T14:00:00Z"));
row.withValue("frequency", "WEEKLY").withValue("day", 1);
assertThat(ScheduleTiming.next(row, NOW)).isEqualTo(Instant.parse("2026-10-19T14:00:00Z"));
row.withValue("frequency", "MONTHLY").withValue("day", 31);
assertThat(ScheduleTiming.next(row, Instant.parse("2026-02-01T00:00:00Z"))).isEqualTo(Instant.parse("2026-02-28T15:00:00Z"));
row.withValue("frequency", "DAILY").withValue("time", "02:30");
assertThat(ScheduleTiming.next(row, Instant.parse("2026-03-08T06:00:00Z"))).isEqualTo(Instant.parse("2026-03-08T08:30:00Z"));
row.withValue("time", "01:30");
assertThat(ScheduleTiming.next(row, Instant.parse("2026-11-01T07:00:00Z"))).isEqualTo(Instant.parse("2026-11-02T07:30:00Z"));
row.withValue("cron", "0 0 9 ? * MON");
assertThat(ScheduleTiming.next(row, NOW)).isEqualTo(Instant.parse("2026-10-19T14:00:00Z"));
row.withValue("cron", "bad cron");
assertThatThrownBy(() -> ScheduleTiming.next(row, NOW)).hasMessageContaining("Invalid schedule");
row.withValue("cron", null).withValue("time_zone", "Mars/Time");
assertThatThrownBy(() -> ScheduleTiming.next(row, NOW)).hasMessageContaining("Invalid schedule");
row.withValue("time_zone", "UTC").withValue("frequency", "MONTHLY").withValue("day", 32);
assertThatThrownBy(() -> ScheduleTiming.next(row, NOW)).hasMessageContaining("Invalid schedule");
}
@Test
void dueRunsAsOwnerAndRecipientRowsAreRecheckedWithAttachments() throws Exception
{
var schedule = schedule("PIVOT", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT"))), "CSV");
assertThat(runner.runDue(NOW.minusSeconds(1))).isZero();
assertThat(runner.runDue(NOW)).isEqualTo(1);
assertThat(Mail.sent).hasSize(2);
assertThat(Mail.sent).extracting(m -> m.getTo().getAddress()).containsExactly("alice@example.test", "bob@example.test");
String ownerCsv = new String(Mail.sent.get(0).getAttachmentList().getFirst().getContents(), StandardCharsets.UTF_8);
String viewerCsv = new String(Mail.sent.get(1).getAttachmentList().getFirst().getContents(), StandardCharsets.UTF_8);
assertThat(ownerCsv).contains("4");
assertThat(viewerCsv).contains("2").doesNotContain("4");
assertThat(Mail.sent.getFirst().getContentList().getFirst().getBody()).contains("&lt;Orders&gt;", "<table>").doesNotContain("<Orders>");
var stored = WatchRecords.get(DataAnalysisTables.SCHEDULE, schedule.getValue("id"));
assertThat(stored.getValueString("last_status")).contains("Sent 2", "denied", "missing");
assertThat(stored.getValueInstant("last_run_at")).isEqualTo(NOW);
assertThat(stored.getValueInstant("next_run_at")).isAfter(NOW);
assertThat(runner.runDue(NOW)).isZero();
assertThat(WatchesTest.Sink.raised).isEmpty();
}
@Test
void providerFailureAndMissingOwnerRaiseVisibleAlerts() throws Exception
{
var schedule = schedule("COLUMN_PROFILE", Map.of("fieldName", "amount"), "XLSX");
config.withMessagingProviderName(null);
assertThat(runner.sendNow(schedule.getValue("id"), NOW)).contains("No messaging provider configured");
assertThat(WatchesTest.Sink.raised).hasSize(1);
assertThat(WatchesTest.Sink.raised.getFirst().getAlertTypeName()).isEqualTo(ScheduleRunner.ALERT_TYPE);
config.withMessagingProviderName("mail").withOwnerSessionSupplier(id -> null);
assertThat(runner.sendNow(schedule.getValue("id"), NOW)).contains("User session unavailable");
}
@Test
void xlsxDeliveryFieldDenialAndManualReviewResults() throws Exception
{
var schedule = schedule("COLUMN_PROFILE", Map.of("fieldName", "amount"), "XLSX");
assertThat(runner.sendNow(schedule.getValue("id"), NOW)).contains("Sent 2");
var attachment = Mail.sent.getFirst().getAttachmentList().getFirst();
assertThat(attachment.getName()).isEqualTo("analysis.xlsx");
try(var workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(new java.io.ByteArrayInputStream(attachment.getContents())))
{
assertThat(workbook.getSheetAt(0).getLastRowNum()).isGreaterThan(5);
}
Mail.fail = true;
assertThat(runner.sendNow(schedule.getValue("id"), NOW)).contains("delivery failed for: alice, bob");
assertThat(instance.getProcess(ScheduleActions.SCHEDULED).getIsHidden()).isTrue();
assertThat(instance.getProcess(ScheduleActions.SEND).getTableName()).isEqualTo(DataAnalysisTables.SCHEDULE);
var input = new RunBackendStepInput().withProcessName(ScheduleActions.SEND).withRecords(List.of(schedule));
var output = new RunBackendStepOutput();
var review = new ScheduleActions.Review();
review.runOnePage(input, output);
assertThat(review.getProcessSummary(output, false).toString()).contains("authorized recipients");
// Runtime has no messaging config: Result reports that real failure rather than claiming success.
var result = new ScheduleActions.Result();
result.runOnePage(input, new RunBackendStepOutput());
assertThat(result.getProcessSummary(output, true).toString()).contains("No messaging provider");
QContext.init(instance, SavedAnalysesTest.viewer("bob", 2));
assertThatThrownBy(() -> runner.sendNow(schedule.getValue("id"), NOW)).isInstanceOf(QException.class);
}
@Test
void recipientMustHaveFullFieldAccessAndHtmlFormatHasNoAttachment() throws Exception
{
var schedule = schedule("TABLE_PROFILE", Map.of("fields", List.of("amount", "status")), "HTML_SUMMARY");
config.withFieldAnalysisAccessResolver(() -> (table, field) ->
field.getName().equals("amount") && QContext.getQSession().getUser().getIdReference().equals("bob")
? com.kingsrook.qbits.dataanalysis.engine.FieldAnalysisAccess.NONE : com.kingsrook.qbits.dataanalysis.engine.FieldAnalysisAccess.FULL);
var restricted = new ScheduleRunner(config, new DataAnalysisService(config));
assertThat(restricted.sendNow(schedule.getValue("id"), NOW)).contains("Sent 1", "bob", "denied", "missing");
assertThat(Mail.sent).hasSize(1);
assertThat(Mail.sent.getFirst().getAttachmentList()).isNullOrEmpty();
var output = new RunBackendStepOutput();
new ScheduleActions.Scheduled().run(new RunBackendStepInput(), output);
assertThat(output.getValue("count")).isNotNull();
}
@Test
void stockSmtpAttachmentLimitationFailsBeforeSending() throws Exception
{
var schedule = schedule("COLUMN_PROFILE", Map.of("fieldName", "amount"), "CSV");
var smtp = new com.kingsrook.qqq.backend.core.model.metadata.messaging.email.EmailMessagingProviderMetaData();
smtp.withName("smtp");
instance.addMessagingProvider(smtp);
config.withMessagingProviderName("smtp");
assertThat(runner.sendNow(schedule.getValue("id"), NOW)).contains("does not support attachments");
assertThat(Mail.sent).isEmpty();
assertThat(WatchesTest.Sink.raised).hasSize(1);
}
}
@@ -93,6 +93,11 @@ class StatisticsTest
// scipy.stats.chi2_contingency([[40,10],[20,30],[4,8]], correction=False); four rare rows merge into Other.
assertThat(Statistics.chiSquare(new double[][] {{40, 10}, {20, 30}, {1, 2}, {1, 2}, {1, 2}, {1, 2}}, false).pValue())
.isCloseTo(5.993665986784811e-5, within(1e-14));
// A sparse independence table (the last row has a single record) pools its smallest categories into
// Other until every expected count is >= 5, instead of giving up with "too few records".
double[][] sparse = {{114, 39, 11}, {99, 28, 4}, {93, 26, 5}, {68, 13, 0}, {32, 6, 2}, {28, 9, 1}, {14, 2, 1}, {7, 2, 0}, {4, 1, 0}, {2, 1, 0}, {1, 0, 0}};
assertThat(Statistics.chiSquare(sparse, false).applicable()).isTrue();
assertThat(Statistics.chiSquare(sparse, false).pValue()).isBetween(0.0, 1.0);
assertThat(Statistics.chiSquare(new double[][] {{3, 3}, {3, 3}}, true).applicable()).isFalse();
assertThat(Statistics.chiSquare(new double[][] {{20, 1}, {20, 1}}, true).applicable()).isFalse();
assertThat(Statistics.chiSquare(new double[][] {{1, 0}, {10, 10}}, true).applicable()).isFalse();
@@ -0,0 +1,292 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisSetupStatus;
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
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.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** "Create rows for analyzable tables" writes the settings that already apply
** and changes nothing; the rows then drive the runtime (including the result
** cache minutes); and the Setup card reports it all. Runs on the memory backend
** with the instance's own runtime, exactly as a host would.
*******************************************************************************/
class TableConfigDefaultsTest extends EngineFixture
{
/*******************************************************************************
** The settings every analyzable table resolves to, as one comparable string.
*******************************************************************************/
private String effective(String table) throws Exception
{
EffectiveConfig c = DataAnalysisRuntime.service().getConfigResolver().resolve(table);
return (c.getEnabled() + " " + c.getMaxRows() + " " + c.getSampleRows() + " " + c.getSampleThresholdRows() + " " + c.getMinGroupSize() + " "
+ c.getCacheTtlMinutes() + " " + new java.util.TreeSet<>(c.getAnalyzableFields()) + " " + new java.util.TreeSet<>(c.getExcludedFields()));
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("unchecked")
private List<ProcessSummaryLine> create() throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(TableConfigDefaultsProcess.NAME);
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
RunProcessOutput output = new RunProcessAction().execute(input);
return ((List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
}
/*******************************************************************************
**
*******************************************************************************/
private List<QRecord> configRows() throws Exception
{
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG).withFilter(new QQueryFilter());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Rows hold the effective settings (table defaults over global defaults),
** excluded tables get none, and every table resolves exactly as before.
*******************************************************************************/
@Test
void rowsRestateTheEffectiveSettingsAndChangeNothing() throws Exception
{
DataAnalysisQBitConfig runtime = DataAnalysisRuntime.service().getConfigResolver().getDefaults();
runtime.withDefaultMaxRows(500).withTableDefaults(Map.of("orders", new TableAnalysisDefaults().withMaxRows(100).withSampleRows(8).withMinGroupSize(2)
.withAnalyzableFields(Set.of("amount", "note")).withExcludedFields(Set.of("note"))));
DataAnalysisRuntime.service().getConfigResolver().invalidate();
String ordersBefore = effective("orders");
String customerBefore = effective("customer");
assertThat(ordersBefore).startsWith("true 100 8 ");
List<ProcessSummaryLine> summary = create();
assertThat(summary).isNotEmpty();
List<QRecord> rows = configRows();
assertThat(rows).extracting(row -> row.getValueString("table_name")).containsExactlyInAnyOrder("customer", "orders");
QRecord orders = rows.stream().filter(row -> "orders".equals(row.getValueString("table_name"))).findFirst().orElseThrow();
assertThat(orders.getValueInteger("max_rows")).isEqualTo(100);
assertThat(orders.getValueInteger("sample_rows")).isEqualTo(8);
assertThat(orders.getValueInteger("min_group_size")).isEqualTo(2);
assertThat(orders.getValueInteger("cache_ttl_minutes")).isEqualTo(60);
assertThat(orders.getValueString("analyzable_fields")).isEqualTo("amount,note");
assertThat(orders.getValueString("excluded_fields")).isEqualTo("note");
QRecord customer = rows.stream().filter(row -> "customer".equals(row.getValueString("table_name"))).findFirst().orElseThrow();
assertThat(customer.getValueInteger("max_rows")).isEqualTo(500);
assertThat(customer.getValueString("analyzable_fields")).isNull();
assertThat(customer.getValueBoolean("enabled")).isTrue();
DataAnalysisRuntime.service().getConfigResolver().invalidate();
assertThat(effective("orders")).isEqualTo(ordersBefore);
assertThat(effective("customer")).isEqualTo(customerBefore);
}
/*******************************************************************************
** Tables excluded by configuration get no row; a table added later gets one on
** the next run; existing rows are never touched and an edit drives the runtime.
*******************************************************************************/
@Test
void insertOnlyExcludedTablesSkippedEditsLive() throws Exception
{
DataAnalysisRuntime.service().getConfigResolver().getDefaults().withExcludeTables(Set.of("customer"));
create();
assertThat(configRows()).extracting(row -> row.getValueString("table_name")).containsExactly("orders");
QRecord orders = configRows().getFirst();
UpdateInput update = new UpdateInput(DataAnalysisTables.TABLE_CONFIG).withRecords(List.of(new QRecord().withValue("id", orders.getValue("id")).withValue("max_rows", 7)));
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new UpdateAction().execute(update);
DataAnalysisRuntime.service().getConfigResolver().invalidate();
assertThat(effective("orders")).startsWith("true 7 ");
instance.addTable(new QTableMetaData().withName("extra").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER)));
create();
assertThat(configRows()).extracting(row -> row.getValueString("table_name")).containsExactlyInAnyOrder("orders", "extra");
assertThat(configRows().stream().filter(row -> "orders".equals(row.getValueString("table_name"))).findFirst().orElseThrow().getValueInteger("max_rows")).isEqualTo(7);
assertThat(create()).singleElement().satisfies(line -> assertThat(line.getMessage()).contains("already has a settings row"));
assertThat(configRows()).hasSize(2);
}
/*******************************************************************************
** The result cache uses the table's settings row, not only the global default:
** a row with more minutes keeps an aged run as a cache hit (the global 60
** minutes would miss it), a row with 0 turns caching off, and a row with 1
** minute misses a run that is two minutes old.
*******************************************************************************/
@Test
void settingsRowCacheMinutesChangeCaching() throws Exception
{
String id = service.startExactRun(column());
age(id, 7200);
assertThat(service.startExactRun(column())).as("default 60 minutes: a two hour old run is not reused").isNotEqualTo(id);
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders").withValue("cache_ttl_minutes", 240));
service.getConfigResolver().invalidate();
String fresh = service.startExactRun(column());
age(fresh, 7200);
assertThat(service.startExactRun(column())).as("row with 240 minutes: the two hour old run is reused").isEqualTo(fresh);
setTtl(1);
age(fresh, 120);
assertThat(service.startExactRun(column())).as("row with 1 minute: a two minute old run is not reused").isNotEqualTo(fresh);
setTtl(0);
String zero = service.startExactRun(column());
assertThat(service.startExactRun(column())).as("row with 0 minutes: never reused").isNotEqualTo(zero);
}
/*******************************************************************************
**
*******************************************************************************/
private ColumnProfileRequest column()
{
return (new ColumnProfileRequest().withTableName("orders").withFieldName("amount"));
}
/*******************************************************************************
** Make a stored run look older.
*******************************************************************************/
private void age(String id, int seconds) throws Exception
{
UpdateInput update = new UpdateInput(DataAnalysisTables.RUN)
.withRecords(List.of(new QRecord().withValue("id", Integer.valueOf(id)).withValue("finished_at", Instant.now().minusSeconds(seconds))));
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new UpdateAction().execute(update);
}
/*******************************************************************************
** Edit the orders settings row's cache minutes.
*******************************************************************************/
private void setTtl(int minutes) throws Exception
{
QRecord row = configRows().stream().filter(r -> "orders".equals(r.getValueString("table_name"))).findFirst().orElseThrow();
UpdateInput update = new UpdateInput(DataAnalysisTables.TABLE_CONFIG).withRecords(List.of(new QRecord().withValue("id", row.getValue("id")).withValue("cache_ttl_minutes", minutes)));
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
new UpdateAction().execute(update);
service.getConfigResolver().invalidate();
}
/*******************************************************************************
** The Result cache line says what each table resolves to, including mixed
** settings, and a settings error is shown as needing attention, not as "on".
*******************************************************************************/
@Test
void resultCacheLineIsAccurate() throws Exception
{
DataAnalysisRuntime.service().getConfigResolver().getDefaults().withDefaultCacheTtlMinutes(0);
DataAnalysisRuntime.service().getConfigResolver().invalidate();
assertThat(DataAnalysisSetupStatus.load().get(1).detail()).startsWith("Off");
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders").withValue("cache_ttl_minutes", 30));
DataAnalysisRuntime.service().getConfigResolver().invalidate();
DataAnalysisSetupStatus.Item mixed = DataAnalysisSetupStatus.load().get(1);
assertThat(mixed.state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
assertThat(mixed.detail()).contains("default 0 min").contains("1 table: off").contains("1 table: 30 min");
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "customer").withValue("cache_ttl_minutes", -5));
DataAnalysisRuntime.service().getConfigResolver().invalidate();
DataAnalysisSetupStatus.Item broken = DataAnalysisSetupStatus.load().get(1);
assertThat(broken.state()).isEqualTo(DataAnalysisSetupStatus.State.UNKNOWN);
assertThat(broken.detail()).contains("could not be read");
}
/*******************************************************************************
** The Setup card: defaults before rows, set up after, from the runtime's own
** resolution; optional features say what is missing.
*******************************************************************************/
@Test
void setupCard() throws Exception
{
List<DataAnalysisSetupStatus.Item> before = DataAnalysisSetupStatus.load();
assertThat(before).extracting(DataAnalysisSetupStatus.Item::label).containsExactly("Analysis settings", "Result cache", "Saved analyses", "Watches", "Schedules");
assertThat(before.get(0).state()).isEqualTo(DataAnalysisSetupStatus.State.DEFAULT);
assertThat(before.get(0).detail()).contains("All 2 analyzable tables are on");
assertThat(before.get(0).nextStep()).contains("Create rows for analyzable tables");
assertThat(before.get(1).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
assertThat(before.get(1).detail()).contains("60 minutes");
assertThat(before.get(3).state()).isEqualTo(DataAnalysisSetupStatus.State.OPTIONAL);
assertThat(before.get(3).detail()).contains("No scheduler is configured");
create();
List<DataAnalysisSetupStatus.Item> after = DataAnalysisSetupStatus.load();
assertThat(after.get(0).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
assertThat(after.get(0).detail()).contains("2 settings rows for 2 analyzable tables");
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("admin", false));
assertThat(DataAnalysisSetupStatus.load().get(2).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
TableData card = DataAnalysisHome.setup();
assertThat(card.getRows()).hasSize(5);
assertThat(card.getRows().getFirst().get("c1")).isEqualTo("Set up");
assertThat(String.valueOf(card.getRows().getFirst().get("c0"))).contains("<a href=");
}
}
@@ -116,7 +116,7 @@ final class TestSupport
*******************************************************************************/
static QRecord saved(String owner, boolean shared)
{
return (new QRecord().withValue("name", "Profile").withValue("table_name", "orders").withValue("kind", "COLUMN_PROFILE")
.withValue("definition_json", "{}").withValue("owner_user_id", owner).withValue("shared", shared));
return (new QRecord().withValue("name", "Profile").withValue("table_name", DataAnalysisTables.RUN).withValue("kind", "COLUMN_PROFILE")
.withValue("definition_json", "{\"fieldName\":\"table_name\"}").withValue("owner_user_id", owner).withValue("shared", shared));
}
}
@@ -319,9 +319,9 @@ class WatchesTest
evaluator.setEnabled(disabled.getValue("id"), false);
assertThat(evaluator.evaluateEnabled(NOW)).isEqualTo(1);
assertThat(stored(disabled).getValue("last_evaluated_at")).isNull();
String html = DataAnalysisHome.render(NOW);
assertThat(html.indexOf("Firing &middot; Spike")).isLessThan(html.indexOf("Ok &middot; Drop"));
assertThat(html).contains("/" + spike.getValue("id") + "\"");
var data = (com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, NOW);
assertThat(data.getRows()).hasSize(1);
assertThat(data.getRows().getFirst().get("c0").toString()).contains("/" + spike.getValue("id") + "\"");
assertThat(instance.getProcess(WatchActions.SCHEDULED).getIsHidden()).isTrue();
assertThat(instance.getProcess(WatchActions.EVALUATE).getTableName()).isEqualTo(DataAnalysisTables.WATCH);
}
@@ -350,7 +350,7 @@ class WatchesTest
void savedAnalysisResolvesAsOwnerAndNoAlertsInstallationStillStoresState() throws Exception
{
QRecord saved = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false)
.withValue("definition_json", "{\"values\":[{\"operator\":\"COUNT\"}]}"));
.withValue("kind", "PIVOT").withValue("table_name", "orders").withValue("definition_json", "{\"values\":[{\"operator\":\"COUNT\"}]}"));
QRecord definition = WatchDetectorsTest.definition("SPIKE", "COUNT", "{\"k\":3}")
.withValue("saved_id", saved.getValue("id")).withValue("table_name", null).withValue("metric_json", null);
QRecord watch = TestSupport.insert(DataAnalysisTables.WATCH, definition);