From d2c9871d98d3bea8e4ce814bc505ecb4a4e38efe Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 21:23:23 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 7 + README.md | 61 +++- docs/DESIGN.md | 2 +- .../dataanalysis/DataAnalysisQBitConfig.java | 55 +++ .../DataAnalysisQBitProducer.java | 7 + .../dataanalysis/DataAnalysisRuntime.java | 20 ++ .../dataanalysis/DataAnalysisSchema.java | 8 + .../dataanalysis/DataAnalysisService.java | 25 ++ .../dataanalysis/api/AnalysisFileSpec.java | 195 +++++++++++ .../dataanalysis/api/DataAnalysisApi.java | 7 +- .../dataanalysis/api/DataAnalysisSchemas.java | 1 + .../qbits/dataanalysis/api/SavedApi.java | 208 ++++++++++++ .../qbits/dataanalysis/api/SavedSchemas.java | 99 ++++++ .../dataanalysis/app/DataAnalysisHome.java | 321 +++++++++--------- .../dataanalysis/app/SavedAnalysisWidget.java | 157 +++++++++ .../dataanalysis/engine/PivotExplorer.java | 3 +- .../engine/RelationshipSupport.java | 3 +- .../engine/StandInAnalysisBackend.java | 3 +- .../DataAnalysisMetaDataProducer.java | 3 +- .../metadata/ScheduleProcesses.java | 93 +++++ .../model/DataAnalysisTables.java | 19 +- .../processes/ScheduleActions.java | 144 ++++++++ .../dataanalysis/saved/AnalysisExport.java | 304 +++++++++++++++++ .../dataanalysis/saved/SavedAnalyses.java | 151 ++++++++ .../dataanalysis/saved/SavedCustomizer.java | 91 +++++ .../dataanalysis/saved/SavedDefinition.java | 313 +++++++++++++++++ .../qbits/dataanalysis/saved/SavedResult.java | 60 ++++ .../qbits/dataanalysis/saved/SqlHandoff.java | 147 ++++++++ .../schedule/ScheduleCustomizer.java | 116 +++++++ .../dataanalysis/schedule/ScheduleRunner.java | 305 +++++++++++++++++ .../dataanalysis/schedule/ScheduleTiming.java | 111 ++++++ .../openapi/qqq-v1/data-analysis.yaml | 297 +++++++++++++++- .../dataanalysis/DataAnalysisApiTest.java | 65 +++- .../dataanalysis/DataAnalysisHomeTest.java | 150 +++----- .../DataAnalysisMetadataTest.java | 7 +- .../DataAnalysisPossibleValuesTest.java | 8 +- .../DataAnalysisSecurityTest.java | 6 +- .../qbits/dataanalysis/SavedAnalysesTest.java | 195 +++++++++++ .../qbits/dataanalysis/SavedSqlH2Test.java | 72 ++++ .../qbits/dataanalysis/SchedulesTest.java | 231 +++++++++++++ .../qbits/dataanalysis/TestSupport.java | 4 +- .../qbits/dataanalysis/WatchesTest.java | 8 +- 42 files changed, 3786 insertions(+), 296 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/api/AnalysisFileSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedApi.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedSchemas.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/app/SavedAnalysisWidget.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/metadata/ScheduleProcesses.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/processes/ScheduleActions.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/saved/AnalysisExport.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedAnalyses.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedDefinition.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedResult.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/saved/SqlHandoff.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java create mode 100644 src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleTiming.java create mode 100644 src/test/java/com/kingsrook/qbits/dataanalysis/SavedAnalysesTest.java create mode 100644 src/test/java/com/kingsrook/qbits/dataanalysis/SavedSqlH2Test.java create mode 100644 src/test/java/com/kingsrook/qbits/dataanalysis/SchedulesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 744d857..31fc8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic ### Added +- 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. diff --git a/README.md b/README.md index 411a05c..d12de46 100644 --- a/README.md +++ b/README.md @@ -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 @@ -66,7 +66,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 +77,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 +244,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 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e3b2d27..f9d9275 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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) diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java index ea24926..64858c6 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitConfig.java @@ -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 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 getRecipientAddressSupplier() + { + return (recipientAddressSupplier); + } + + /******************************************************************************* + ** Bind the host directory's user-to-email lookup. + *******************************************************************************/ + public DataAnalysisQBitConfig withRecipientAddressSupplier(java.util.function.Function value) + { + recipientAddressSupplier = value; + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitProducer.java b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitProducer.java index 4e38760..5e22281 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/DataAnalysisQBitProducer.java @@ -144,6 +144,13 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer +{ + /******************************************************************************* + ** 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 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 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 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 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 + { + /******************************************************************************* + ** 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); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisApi.java b/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisApi.java index ebd7532..692d5c8 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisApi.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisApi.java @@ -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); diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisSchemas.java b/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisSchemas.java index 2a0d845..d095fa7 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisSchemas.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisSchemas.java @@ -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); } diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedApi.java b/src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedApi.java new file mode 100644 index 0000000..abbc6e2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedApi.java @@ -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 body, String pathTable) throws QException + { + if(!(body.get("kind") instanceof String kind)) + { + throw new QBadRequestException("An analysis kind is required."); + } + Map 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) 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())); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedSchemas.java b/src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedSchemas.java new file mode 100644 index 0000000..b3d6770 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/api/SavedSchemas.java @@ -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 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 components() + { + Map result = new LinkedHashMap<>(); + Map 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 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 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")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/app/DataAnalysisHome.java b/src/main/java/com/kingsrook/qbits/dataanalysis/app/DataAnalysisHome.java index e76f51f..c9012b8 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/app/DataAnalysisHome.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/app/DataAnalysisHome.java @@ -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,49 @@ 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 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 widgets() + { + return (List.of(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 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,177 +114,172 @@ 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(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. + ** One standard group per subject, with real counts or an unavailable explanation. *******************************************************************************/ - public static String render(Instant now) throws QException + public static MultiStatisticsData statistics(Instant now) throws QException { - StringBuilder html = new StringBuilder("
"); - String userId = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getIdReference(); - html.append("

Saved analyses

"); - if(userId == null) + List groups = new ArrayList<>(); + var saved = group("Saved analyses", DataAnalysisTables.SAVED); + if(canRead(DataAnalysisTables.SAVED)) { - html.append("

Sign in to see your saved analyses and stars.

"); + List 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))); } - 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("

Mine: ").append(AnalysisFormatting.number(mine, 0)).append(" · Shared (visible to you): ").append(AnalysisFormatting.number(shared, 0)).append("

"); - if(canRead(DataAnalysisTables.SAVED_STAR)) - { - 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("

Starred by me: ").append(AnalysisFormatting.number(starred, 0)).append("

"); - } - if(mine == 0 && shared == 0) - { - html.append("

No saved analyses yet. Open any table and choose Analyze on a column header to explore its data.

"); - } - link(html, DataAnalysisTables.SAVED); - } - else - { - html.append("

Saved analyses are unavailable: you need data_analysis.saved.read.

"); - } - html.append("

Watches

"); + 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("

Total: ").append(AnalysisFormatting.number(total, 0)).append(" · Enabled: ").append(AnalysisFormatting.number(enabled, 0)).append(" · Firing: ").append(AnalysisFormatting.number(firing, 0)).append("

"); - if(total == 0) - { - html.append("

No watches yet.

"); - } - 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 recent = new QueryAction().execute(watches).getRecords(); - if(!recent.isEmpty()) - { - html.append(""); - } - 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("

Watches are unavailable: you need data_analysis.watch.read.

"); + 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("

Recent analysis runs

"); - if(userId == null) - { - html.append("

Sign in to see your recent analysis runs.

"); - } - 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 runs = new QueryAction().execute(query).getRecords(); - if(runs.isEmpty()) - { - html.append("

No analysis runs yet.

"); - } - else - { - html.append("
    "); - 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("
  • ").append(escape(tableLabel)).append(" · ") - .append(escape(words(run.getValueString("operation")))).append("
    Status
    ") - .append(escape(words(run.getValueString("status")))).append("
    Finished
    ") - .append(run.getValue("finished_at") == null ? "Not finished" : escape(AnalysisFormatting.instant(run.getValueInstant("finished_at")))) - .append("
  • "); - } - html.append("
"); - } - link(html, DataAnalysisTables.RUN); - } - else - { - html.append("

Run history is unavailable: you need data_analysis.run.read.

"); - } - return (html.append("
").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 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 values(Object... values) + { + Map 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 ("" + escape(label) + ""); + } + + /******************************************************************************* + ** Metadata labels and stored names are never trusted markup. *******************************************************************************/ private static String escape(String value) { - return (value == null ? "Unknown" : value.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">").replace("'", "'")); + return (Objects.toString(value, "").replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")); } - + /******************************************************************************* + ** Explicit checks distinguish denied sections from empty sections. + *******************************************************************************/ + private 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 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. *******************************************************************************/ private static int count(String table, QQueryFilter filter) throws QException { @@ -278,16 +287,4 @@ public class DataAnalysisHome extends AbstractWidgetRenderer 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("View all"); - } } diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/app/SavedAnalysisWidget.java b/src/main/java/com/kingsrook/qbits/dataanalysis/app/SavedAnalysisWidget.java new file mode 100644 index 0000000..aad5441 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/app/SavedAnalysisWidget.java @@ -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 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 labels = new ArrayList<>(); + List 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 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 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("View saved analysis"); + return (new RenderWidgetOutput(data)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/engine/PivotExplorer.java b/src/main/java/com/kingsrook/qbits/dataanalysis/engine/PivotExplorer.java index b436fc2..46245a0 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/engine/PivotExplorer.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/engine/PivotExplorer.java @@ -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()); diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/engine/RelationshipSupport.java b/src/main/java/com/kingsrook/qbits/dataanalysis/engine/RelationshipSupport.java index aad7842..f7f2162 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/engine/RelationshipSupport.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/engine/RelationshipSupport.java @@ -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."); diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/engine/StandInAnalysisBackend.java b/src/main/java/com/kingsrook/qbits/dataanalysis/engine/StandInAnalysisBackend.java index 48b342f..14fefea 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/engine/StandInAnalysisBackend.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/engine/StandInAnalysisBackend.java @@ -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()); } diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/metadata/DataAnalysisMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/dataanalysis/metadata/DataAnalysisMetaDataProducer.java index ad08846..6121fb7 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/metadata/DataAnalysisMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/metadata/DataAnalysisMetaDataProducer.java @@ -66,7 +66,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); } diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/metadata/ScheduleProcesses.java b/src/main/java/com/kingsrook/qbits/dataanalysis/metadata/ScheduleProcesses.java new file mode 100644 index 0000000..fc1f6ae --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/metadata/ScheduleProcesses.java @@ -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 +{ + /******************************************************************************* + ** 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/model/DataAnalysisTables.java b/src/main/java/com/kingsrook/qbits/dataanalysis/model/DataAnalysisTables.java index 1e77895..34b9327 100644 --- a/src/main/java/com/kingsrook/qbits/dataanalysis/model/DataAnalysisTables.java +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/model/DataAnalysisTables.java @@ -239,8 +239,15 @@ public final class DataAnalysisTables .withField(field("chart_kind", QFieldType.STRING).withPossibleValueSourceName("data_analysis_chart_kind")) .withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true)) .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)) @@ -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") @@ -305,7 +316,7 @@ public final class DataAnalysisTables for(QTableMetaData table : tables) { List 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) diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/processes/ScheduleActions.java b/src/main/java/com/kingsrook/qbits/dataanalysis/processes/ScheduleActions.java new file mode 100644 index 0000000..d5e56a3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/processes/ScheduleActions.java @@ -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 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 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 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 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())); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/saved/AnalysisExport.java b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/AnalysisExport.java new file mode 100644 index 0000000..62dddec --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/AnalysisExport.java @@ -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 columns, List> 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 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 records = new ArrayList<>(); + for(List 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 columns = new ArrayList<>(List.of("Section", "Metric", "Value")); + List> 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 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 headlines(SavedResult result) + { + List 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 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 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> rows, String section, List metrics) + { + metrics.forEach(m -> rows.add(row(section, m.getLabel(), value(m)))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedAnalyses.java b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedAnalyses.java new file mode 100644 index 0000000..0a34225 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedAnalyses.java @@ -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 list() throws QException + { + Set stars = new HashSet<>(); + for(QRecord row : query(DataAnalysisTables.SAVED_STAR, DrillFilters.condition("user_id", QCriteriaOperator.EQUALS, user()))) + { + stars.add(row.getValue("saved_id")); + } + List 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 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 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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedCustomizer.java b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedCustomizer.java new file mode 100644 index 0000000..c5de253 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedCustomizer.java @@ -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 preInsert(InsertInput input, List 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 preUpdate(UpdateInput input, List records, boolean preview, Optional> 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())); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedDefinition.java b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedDefinition.java new file mode 100644 index 0000000..056036b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedDefinition.java @@ -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> 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 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 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 object(String json) throws QBadRequestException + { + try + { + Object value = JsonUtils.toObject(json, Object.class); + if(value instanceof Map map) + { + return (new LinkedHashMap<>((Map) 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 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 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 fields(Object value) + { + Set 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 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 body = body(); + Set 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 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedResult.java b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedResult.java new file mode 100644 index 0000000..954cde5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SavedResult.java @@ -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 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 warnings) + { + java.util.ArrayList 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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SqlHandoff.java b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SqlHandoff.java new file mode 100644 index 0000000..681969b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/saved/SqlHandoff.java @@ -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> 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 statements = new ArrayList<>(); + CAPTURE.set(statements); + try + { + definition.run(service, true); + } + finally + { + CAPTURE.remove(); + } + java.util.Comparator 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); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleCustomizer.java b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleCustomizer.java new file mode 100644 index 0000000..884ea43 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleCustomizer.java @@ -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 CONFIG = Set.of("saved_id", "cron", "frequency", "day", "time", "time_zone", "enabled", "recipients", "format"); + + /******************************************************************************* + ** Disabled empty drafts may be completed later. + *******************************************************************************/ + @Override + public List preInsert(InsertInput input, List 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 preUpdate(UpdateInput input, List records, boolean preview, Optional> 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 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())); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java new file mode 100644 index 0000000..5aee7b8 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleRunner.java @@ -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 skipped = new ArrayList<>(); + List 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("

").append(escape(name)).append("

"); + for(String warning : result.warnings()) + { + html.append("

").append(escape(warning)).append("

"); + } + html.append("
"); + for(var metric : AnalysisExport.headlines(result)) + { + html.append("
").append(escape(metric.getLabel())).append("
").append(escape(AnalysisExport.value(metric))).append("
"); + } + html.append("
"); + table.columns().forEach(c -> html.append("")); + html.append(""); + for(var row : table.rows().stream().limit(20).toList()) + { + html.append(""); + row.forEach(value -> html.append("")); + html.append(""); + } + return (html.append("
").append(escape(c)).append("
").append(escape(value)).append("
").toString()); + } + + /******************************************************************************* + ** Source labels and values are text, never trusted email markup. + *******************************************************************************/ + private static String escape(String value) + { + return (Objects.toString(value, "").replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleTiming.java b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleTiming.java new file mode 100644 index 0000000..90a84f2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/dataanalysis/schedule/ScheduleTiming.java @@ -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); + } +} diff --git a/src/main/resources/openapi/qqq-v1/data-analysis.yaml b/src/main/resources/openapi/qqq-v1/data-analysis.yaml index 0541bf6..332a379 100644 --- a/src/main/resources/openapi/qqq-v1/data-analysis.yaml +++ b/src/main/resources/openapi/qqq-v1/data-analysis.yaml @@ -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: @@ -1641,9 +1764,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\ diff --git a/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisApiTest.java b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisApiTest.java index b5c8256..b528fda 100644 --- a/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisApiTest.java +++ b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisApiTest.java @@ -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"); + } } diff --git a/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisHomeTest.java b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisHomeTest.java index fc34518..46288f7 100644 --- a/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisHomeTest.java +++ b/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisHomeTest.java @@ -20,131 +20,87 @@ 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.", "

"); - 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("·").doesNotContain("Â", "later", "phase", " 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 & 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) + { + 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 <analysis> & review", "Column Profile", "2026-10-13 18:10", "2026-10-13 18:06", ""); - assertThat(html.split("
  • ", -1)).hasSize(6); - assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "/1\""); - assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "\">View all"); + 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", "