Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae57a9442b
|
||
|
|
472fcbf8dd
|
||
|
|
a707215317
|
||
|
|
0e9c4ec2b8
|
||
|
|
a62834c089
|
||
|
|
880977b19a
|
||
|
|
53d17b784e | ||
|
|
4354239fd7
|
||
|
|
21ca0bb158
|
||
|
|
0ec2b55f0f | ||
|
|
9c81be5223
|
||
|
|
0012d9b3fa
|
||
|
|
f2fd8b1b0f
|
||
|
|
35731a101d | ||
|
|
1836afce45
|
||
|
|
ccc93ea803 | ||
|
|
e7926cbb20
|
||
|
|
2dcc073f51 | ||
|
|
36f726417c
|
||
|
|
f35f8130b7
|
||
|
|
665c14bd6d
|
||
|
|
d218ad0f4f
|
||
|
|
a398dbf55a
|
||
|
|
59322faaad
|
||
|
|
368d2c0e3a | ||
|
|
68067df16b | ||
|
|
8cc13ff7e7 | ||
|
|
6a520e5452 | ||
|
|
b1ede9f887 | ||
|
|
81552784e9 | ||
|
|
a68d557fb2 | ||
|
|
36527b06ff | ||
|
|
7c78003765
|
||
|
|
2b4b2c48e5 | ||
|
|
e82960ac52
|
||
|
|
ce127037e4 | ||
|
|
20545be7fc
|
||
|
|
16b639ce40 | ||
|
|
2339aea28c | ||
|
|
96d2e48e47 | ||
|
|
1d2c2b0029
|
||
|
|
21671b3df3
|
||
|
|
9d1d712040 | ||
|
|
80e86fd207 | ||
|
|
2c1fa83cfd
|
||
|
|
666020b5b9
|
||
|
|
25d03c1514 | ||
|
|
442eb4398c
|
||
|
|
3aa728d88a
|
||
|
|
51ab908b11 | ||
|
|
3474b0c188
|
||
|
|
b12cadfc75 | ||
|
|
6065d2d1c1
|
||
|
|
0487f3ac40 | ||
|
|
546196af6d
|
||
|
|
0e0cc0b83d | ||
|
|
73de290a93
|
||
|
|
eeec501834 | ||
|
|
a9b4f076f9
|
||
|
|
e11e1464cd | ||
|
|
bab162e77c
|
||
|
|
cdce36d119
|
||
|
|
04d3c01cf3
|
||
|
|
69800f755c | ||
|
|
f8d1ed787c |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
+22
-1
@@ -4,8 +4,26 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Reconciled legacy release-branch history with the current Gitea CI while retaining the develop SNAPSHOT revision, so normal RC cuts can merge cleanly.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed HTTP 500 ("Unsupported class ...NowWithOffset for converting to Instant") from every analysis whose filter holds a relative date (a filter expression such as "now minus one day" or "this month") on a date or date-time field: the pivot, profiles, comparisons and the stand-in backend converted filter values to the field's type before querying and did not expect an expression. Expressions are now left for core to evaluate when the query runs, so "printed in the last day" works from the Analyses screens, over the UI API (`POST /qbit/data-analysis/tables/{table}/pivot`) and in a saved or scheduled analysis, where it means the last day on every run. Drill-through filters carry the expression.
|
||||
- Reject saved analyses with a NULL or false shared flag and no read owner in the generated schema. Existing hosts must apply the documented constraint correction through a new migration.
|
||||
- Scheduled analyses and watches work under OAuth2. When the host declares the `data-analysis` service principal (QQQ service identities, `DataAnalysisQBitConfig.withServicePrincipal`), schedule and watch inventory runs as that principal, and owner and recipient runs go on their behalf through it: their current access intersected with the principal's. The self-made `data-analysis-scheduler` session was rejected under OAuth2 (`Invalid session in request` on every `data_analysis_run_schedules` run). With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
|
||||
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered build resource.
|
||||
- The Data Analysis home no longer shows the engine Setup card or "Unavailable: READ permission required." shells. Setup is admin-only (hidden from everyone else) and off the home; hosts place `DataAnalysisQBitProducer.SYSTEM_WIDGETS` on their Admin area. Home widgets the user cannot read are omitted from metadata, and unreadable groups are left out of the overview.
|
||||
|
||||
### Added
|
||||
|
||||
- Complete host-neutral in-app help bundle with live metadata references and a core help coverage gate.
|
||||
|
||||
- Analysis Settings table action **Create rows for analyzable tables**: writes the effective settings as editable `data_analysis_table_config` rows (insert-only, no change in behavior).
|
||||
- Setup card on the Analyses home (settings, result cache, saved analyses, watches, schedules).
|
||||
- Help bundle `help/qbit-data-analysis.yaml`: every table says what it is for and how its rows get created.
|
||||
- `DataAnalysisTablesGuardrailTest` and `TableConfigDefaultsTest` (including a test that a settings row's `cache_ttl_minutes` changes result caching).
|
||||
- Phase f saved execution for all seven kinds, core owner-or-shared READ with owner-only WRITE, typed definition validation and viewer-specific field reduction.
|
||||
- Idempotent personal star endpoints and visible saved list; transient viewer runs, core CSV/XLSX downloads and QueryStat-backed SQL Workbench handoff.
|
||||
- Five-minute owner-session schedule runner, per-recipient FULL access checks and record-scoped reruns, core messaging with attachments, explicit failure statuses/alerts, timezone/weekly/monthly/cron timing and streamed ETL Send now.
|
||||
@@ -36,12 +54,15 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
|
||||
### Changed
|
||||
|
||||
- UI API extension data-analysis 1.5.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, contract validation for operationIds and schema references, and HTTP error envelope regression assertions.
|
||||
|
||||
- Pin QQQ 4.1.0-RC.7 and replace the temporary help loader with core bundle registration and startup sync.
|
||||
|
||||
- Boolean pivot/crosstab headers now say Yes / No / (Blank). Pivot measures use human labels such as Count, Average Amount, Sum of Amount, Distinct Email, Median Amount and 90th percentile of Amount.
|
||||
- Watch DDL includes owner/date fields and last value/message; existing databases need an owner/date backfill before enabling watches.
|
||||
|
||||
- Scalable estimates sample each native aggregate without primary-key IN lists, scale counts using observed sample sizes, retain exact population totals and unscaled ratios, and drill over the full population. Portable estimates use one primary-key boundary and report unscaled prefix counts.
|
||||
|
||||
- Target QQQ 4.1.0-RC.6; local verification uses `-Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT`.
|
||||
- Empty-string and temporal drills round-trip through core HTTP filter interpretation.
|
||||
|
||||
### Previously added
|
||||
|
||||
@@ -10,7 +10,7 @@ Twenty-one HTTP operations are available under `/qqq/v1/qbit/data-analysis` (ext
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21; QQQ **4.1.0-RC.6+**.
|
||||
- Java 21; QQQ **4.1.0-RC.7+**.
|
||||
- A configured QQQ backend for the qbit tables and authenticated sessions with permissions and security keys.
|
||||
- Core aggregate and table-feature permission APIs ([qqq#1139](https://github.com/QRun-IO/qqq/pull/1139)) and the UI API extension point ([qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)). Middleware Javalin remains optional; the producer registers endpoints only when it is present.
|
||||
|
||||
@@ -32,6 +32,16 @@ new DataAnalysisQBitProducer()
|
||||
|
||||
The producer installs metadata into the instance. It scans only `com.kingsrook.qbits.dataanalysis.metadata`; helpers and renderers are not scanned as component producers. Set `withParentAppName("admin")` to nest the app under an existing parent.
|
||||
|
||||
## Starter defaults and setup
|
||||
|
||||
Data Analysis ships no SQL seed: `data_analysis_table_config` depends on which tables the host has and on the code configuration (`defaultMaxRows`, `tableDefaults`, `includeTables`, `excludeTables` and the other defaults), so its rows are computed at run time.
|
||||
|
||||
- With no rows, every analyzable table is on with the configured defaults. That is a complete, working setup.
|
||||
- Open Analysis Settings and run the table action **Create rows for analyzable tables**. It adds one row per analyzable table that has none, holding the settings that already apply (table defaults over global defaults). Nothing changes until you edit a row. It is insert-only: it never touches an existing row, and running it again only adds rows for tables added since.
|
||||
- Rows pin their values: after a row exists, later changes to the code defaults do not reach that table, edit the row instead. `cache_ttl_minutes` is what the exact-run result cache uses for that table (0 turns caching off); `TableConfigDefaultsTest` proves a row changes caching.
|
||||
- The **Setup** card on the Analyses home lists settings, result cache, saved analyses, watches and schedules with their state and next step, using the same resolution the runtime uses.
|
||||
- Every table has help (shown when it is empty) that says how its rows get created. Saved analyses, stars, schedules, watches and runs are user content or logs and start empty by design.
|
||||
|
||||
## Tables
|
||||
|
||||
QQQ names and physical SQL table names are identical. `backendName` in configuration identifies the backend connection; it is not a SQL table name. All tables have `id`, `createDate` and `modifyDate`; SQL columns use inferred snake_case names.
|
||||
@@ -90,6 +100,14 @@ 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`.
|
||||
|
||||
Installations created with the earlier generated constraint also need a new host migration after ensuring the read-owner projection is populated. SQL CHECK constraints accept UNKNOWN, so `shared = TRUE` allowed a null shared flag and null read owner. Only explicit sharing may omit the read owner:
|
||||
|
||||
```sql
|
||||
ALTER TABLE data_analysis_saved
|
||||
DROP CONSTRAINT IF EXISTS ck_data_analysis_saved_read_owner,
|
||||
ADD CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared IS TRUE OR read_owner_user_id IS NOT NULL);
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Source fields must be present in personalized metadata, pass `FieldSecurityLockHelper`, and pass the configured allow/exclude lists. FK labels are queried with enforcement before core PVS formatting; unreadable related records or label fields fall back to the source id. This avoids RC.5's ordinary table-PVS translator's trusted permission bypass.
|
||||
@@ -177,7 +195,7 @@ Requests and drills reuse core's `QueryFilter` JSON shape and schema reference.
|
||||
Regenerate deliberately after an API change:
|
||||
|
||||
```bash
|
||||
./mvnw -o -q test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT
|
||||
./mvnw -q test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true
|
||||
```
|
||||
|
||||
## Pivot backend and field access
|
||||
@@ -192,7 +210,7 @@ The planner first requests count-ordered top-N groups for every dimension (defau
|
||||
|
||||
Hosts can supply `withFieldAnalysisAccessResolver(() -> resolver)`. FULL fields alone appear in capabilities and may be selected. EXACT_MATCH_ONLY and NONE get the same 400 as an unknown field; user-authored filters are passed through for core enforcement. Generated drills using a restricted field (including representative primary keys) are disabled. Related PVS display labels also require FULL access. Exact-run cache identities hash the sorted FULL-field list, the optional core access fingerprint, and related label access.
|
||||
|
||||
The default resolver discovers the optional core `fieldaccess.FieldAccessHelper` class and its static methods once. FULL maps to FULL, REVEALABLE to EXACT_MATCH_ONLY, MASKED/HIDDEN to NONE. Without that class, legacy field locks supply FULL/NONE. Replace the reflective calls when the core package ships. Reflection tests use a local stand-in helper rather than shadowing a future real core class at its FQN; the installed snapshot exercises the actual fallback selection. Exact-run persistence continues to support profiles only; pivot results are synchronous.
|
||||
The default resolver discovers the optional core `fieldaccess.FieldAccessHelper` class and its static methods once. FULL maps to FULL, REVEALABLE to EXACT_MATCH_ONLY, MASKED/HIDDEN to NONE. Without that class, legacy field locks supply FULL/NONE. Replace the reflective calls when the core package ships. Reflection tests use a local stand-in helper rather than shadowing a future real core class at its FQN; the pinned RC.7 exercises the actual core tier resolver selection. Exact-run persistence continues to support profiles only; pivot results are synchronous.
|
||||
|
||||
## Backend selection
|
||||
|
||||
@@ -294,13 +312,26 @@ The Analyses home uses standard `multiStatistics` and `table` widgets: mine/shar
|
||||
|
||||
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.
|
||||
|
||||
## In-app help
|
||||
|
||||
The qbit registers `src/main/resources/help/qbit-data-analysis.yaml` as a QBIT bundle.
|
||||
Hosts with the core `helpContent` table receive it through QQQ's startup sync; registration
|
||||
is harmless without that table. The app help follows `appName` when you configure a custom name.
|
||||
To override text, register your own `layer: HOST` bundle with the same entry key and role
|
||||
(using your configured app name for app help). HOST entries take precedence over QBIT entries;
|
||||
QQQ preserves manually edited help during sync.
|
||||
|
||||
`DataAnalysisTablesGuardrailTest` uses `HelpBundleAssertions` to check owned metadata,
|
||||
summary length and live references, and checks widget and possible-value-source coverage.
|
||||
Update the bundle's literal version alongside the POM version when releasing.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./mvnw -o -q verify -Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT
|
||||
./mvnw -q verify
|
||||
```
|
||||
|
||||
The wrapper pins Maven 3.9.11. On Windows use `mvnw.cmd`. Verification runs Checkstyle, a shared JUnit 5/AssertJ contract suite on memory and H2, PostgreSQL via Testcontainers when Docker is available, and JaCoCo gates of 70% instructions and 90% classes. The suite checks numerical references, every exact count/drill pair, full-population estimate drills, security, guarded cache JSON, native sampling, asynchronous jobs and cache invalidation. The POM targets RC.6; until its release, always supply the locally installed snapshot override shown above. RDBMS, H2, Testcontainers PostgreSQL and the PostgreSQL driver are test-scoped; middleware Javalin is optional.
|
||||
The wrapper pins Maven 3.9.11. On Windows use `mvnw.cmd`. Verification runs Checkstyle, a shared JUnit 5/AssertJ contract suite on memory and H2, PostgreSQL via Testcontainers when Docker is available, and JaCoCo gates of 70% instructions and 90% classes. The suite checks numerical references, every exact count/drill pair, full-population estimate drills, security, guarded cache JSON, native sampling, asynchronous jobs and cache invalidation. The POM pins QQQ 4.1.0-RC.7 from Maven Central. RDBMS, H2, Testcontainers PostgreSQL and the PostgreSQL driver are test-scoped; middleware Javalin is optional.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
No schema change and nothing required of hosts.
|
||||
|
||||
- New table action **Create rows for analyzable tables** on Analysis Settings. Run it once if you want the effective settings visible and editable as rows. It changes no behavior, is insert-only, and only adds rows for tables that have none. After a row exists it wins over later changes to the code defaults for that table.
|
||||
- New **Setup** widget first on the Analyses home, and table help text. Hosts that override help on a table keep their own.
|
||||
@@ -18,7 +18,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.1.0-RC.1</revision>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<revision>0.3.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -27,7 +28,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -128,7 +129,35 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-data-analysis.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-data-analysis.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -215,7 +244,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -427,6 +427,28 @@ public class DataAnalysisQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
private String servicePrincipal = "data-analysis";
|
||||
|
||||
/*******************************************************************************
|
||||
** The service principal schedules and watches run as when the host declares
|
||||
** it (QQQ service identities): inventory as the principal, owner and
|
||||
** recipient runs on their behalf through it. Undeclared: the previous local
|
||||
** sessions.
|
||||
*******************************************************************************/
|
||||
public String getServicePrincipal()
|
||||
{
|
||||
return (servicePrincipal);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for servicePrincipal (default data-analysis).
|
||||
*******************************************************************************/
|
||||
public DataAnalysisQBitConfig withServicePrincipal(String servicePrincipal)
|
||||
{
|
||||
this.servicePrincipal = servicePrincipal;
|
||||
return (this);
|
||||
}
|
||||
|
||||
private String messagingProviderName;
|
||||
private String fromAddress;
|
||||
private transient java.util.function.Function<String, String> recipientAddressSupplier;
|
||||
|
||||
@@ -21,10 +21,16 @@
|
||||
package com.kingsrook.qbits.dataanalysis;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi;
|
||||
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermission;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
@@ -39,13 +45,48 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-data-analysis";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// Widgets that show engine configuration. They are registered with the qbit //
|
||||
// (admin-only, hidden from everyone else) but kept off the business home; the //
|
||||
// host places them on its Admin area, e.g. withWidgets(SYSTEM_WIDGETS). //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
public static final List<String> SYSTEM_WIDGETS = List.of(DataAnalysisHome.SETUP);
|
||||
|
||||
private DataAnalysisQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered properties resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = DataAnalysisQBitProducer.class.getClassLoader().getResourceAsStream("qbit-data-analysis.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing qbit-data-analysis.properties; build with Maven to generate the artifact version"));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw (new IllegalStateException("Missing or unfiltered version in qbit-data-analysis.properties; build with Maven resource filtering"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not read artifact version from qbit-data-analysis.properties", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent configuration setter.
|
||||
*******************************************************************************/
|
||||
@@ -171,6 +212,11 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
|
||||
{
|
||||
instance.getApp(qBitConfig.getParentAppName()).withChild(instance.getApp(qBitConfig.getAppName()));
|
||||
}
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(instance, "help/qbit-data-analysis.yaml");
|
||||
HelpBundles.of(instance).getBundles().getLast().getEntries().stream()
|
||||
.filter(entry -> "app:dataAnalysis;slot:help".equals(entry.getKey()))
|
||||
.forEach(entry -> entry.setKey("app:" + qBitConfig.getAppName() + ";slot:help"));
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public final class DataAnalysisSchema
|
||||
// a null read-owner projection means "shared": make the database refuse it //
|
||||
// on a private row, so a row written outside QQQ fails closed, not open //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
columns.add(" CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared = TRUE OR read_owner_user_id IS NOT NULL)");
|
||||
columns.add(" CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared IS TRUE OR read_owner_user_id IS NOT NULL)");
|
||||
}
|
||||
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
|
||||
if(table.getUniqueKeys() != null)
|
||||
|
||||
@@ -76,7 +76,7 @@ public class DataAnalysisApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("data-analysis").withSemanticVersion("1.4.0")
|
||||
return (new MiddlewareSpecExtension().withName("data-analysis").withSemanticVersion("1.5.0")
|
||||
.withDescription("Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user.")
|
||||
.withSpec(SavedApi.Run.class).withSpec(SavedApi.ListSaved.class).withSpec(SavedApi.Star.class).withSpec(SavedApi.Unstar.class)
|
||||
.withSpec(SavedApi.Sql.class).withSpec(AnalysisFileSpec.class).withSpec(AnalysisFileSpec.Saved.class)
|
||||
|
||||
@@ -28,14 +28,17 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitProducer;
|
||||
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.permissions.DataAnalysisPermissions;
|
||||
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.permissions.UseOtherPermissionNameCustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -54,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
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;
|
||||
@@ -63,6 +67,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final String SETUP = "dataAnalysisSetup";
|
||||
public static final String HOME_WIDGET = "dataAnalysisHome";
|
||||
public static final String STARRED = "dataAnalysisStarred";
|
||||
public static final String FIRING = "dataAnalysisFiring";
|
||||
@@ -81,7 +86,39 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(widget(), widget(STARRED, "Starred analyses", WidgetType.TABLE), widget(FIRING, "Firing watches", WidgetType.TABLE), widget(RECENT, "My recent runs", WidgetType.TABLE)));
|
||||
return (List.of(setupWidget(), widget().withPermissionRules(readRules(DataAnalysisTables.SAVED)),
|
||||
widget(STARRED, "Starred analyses", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.SAVED)),
|
||||
widget(FIRING, "Firing watches", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.WATCH)),
|
||||
widget(RECENT, "My recent runs", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.RUN))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Names of the widgets placed on the business home. Setup is system content and
|
||||
** is deliberately absent; hosts place {@link DataAnalysisQBitProducer#SYSTEM_WIDGETS}
|
||||
** on their Admin area instead.
|
||||
*******************************************************************************/
|
||||
public static List<String> homeWidgetNames()
|
||||
{
|
||||
return (widgets().stream().map(QWidgetMetaData::getName).filter(name -> !DataAnalysisQBitProducer.SYSTEM_WIDGETS.contains(name)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card shows engine configuration, so only Data Analysis admins get it;
|
||||
** everyone else has it omitted from metadata.
|
||||
*******************************************************************************/
|
||||
private static QWidgetMetaData setupWidget()
|
||||
{
|
||||
return (widget(SETUP, "Setup", WidgetType.TABLE).withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withDenyBehavior(DenyBehavior.HIDDEN).withCustomPermissionChecker(UseOtherPermissionNameCustomPermissionChecker.build(DataAnalysisPermissions.ADMIN))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A data widget is omitted from metadata for users who cannot read its table.
|
||||
*******************************************************************************/
|
||||
private static QPermissionRules readRules(String table)
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withDenyBehavior(DenyBehavior.HIDDEN)
|
||||
.withCustomPermissionChecker(TableReadWidgetPermissionChecker.build(table)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -100,7 +137,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
public static QAppMetaData app(DataAnalysisQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("insights")).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList())
|
||||
.withIcon(new QIcon("insights")).withWidgets(homeWidgetNames())
|
||||
.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());
|
||||
@@ -127,6 +164,10 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
*******************************************************************************/
|
||||
public static QWidgetData render(String name, Instant now) throws QException
|
||||
{
|
||||
if(SETUP.equals(name))
|
||||
{
|
||||
return (setup());
|
||||
}
|
||||
if(HOME_WIDGET.equals(name))
|
||||
{
|
||||
return (statistics(now));
|
||||
@@ -167,46 +208,70 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One standard group per subject, with real counts or an unavailable explanation.
|
||||
** The Setup card: each settings or content table, its state and the next step.
|
||||
*******************************************************************************/
|
||||
public static TableData setup() throws QException
|
||||
{
|
||||
TableData data = new TableData("Setup", List.of(new TableData.Column("html", "Part", "c0", null, "left"), new TableData.Column("text", "State", "c1", null, "left"),
|
||||
new TableData.Column("text", "What it means", "c2", null, "left"), new TableData.Column("text", "Next step", "c3", null, "left")), new ArrayList<>())
|
||||
.withNoRowsFoundHTML("Nothing to set up.").withRowsPerPage(10);
|
||||
for(DataAnalysisSetupStatus.Item item : DataAnalysisSetupStatus.load())
|
||||
{
|
||||
String path = QContext.getQInstance().getTablePath(item.tableName());
|
||||
String part = "<a href=\"" + escape(path) + "\">" + escape(item.label()) + "</a>";
|
||||
String state = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
case UNAVAILABLE -> "Unavailable";
|
||||
case UNKNOWN -> "Needs attention";
|
||||
};
|
||||
data.getRows().add(values(part, state, item.detail(), item.nextStep()));
|
||||
}
|
||||
return (data);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One standard group per subject the viewer can read; unreadable subjects are left out.
|
||||
*******************************************************************************/
|
||||
public static MultiStatisticsData statistics(Instant now) throws QException
|
||||
{
|
||||
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
|
||||
var saved = group("Saved analyses", DataAnalysisTables.SAVED);
|
||||
if(canRead(DataAnalysisTables.SAVED))
|
||||
{
|
||||
var saved = group("Saved analyses");
|
||||
List<SavedAnalyses.Entry> entries = DataAnalysisRuntime.saved().list();
|
||||
String user = SavedAnalyses.user();
|
||||
saved.withStatisticList(List.of(stat("Mine", (int) entries.stream().filter(e -> e.owner().equals(user)).count(), DataAnalysisTables.SAVED),
|
||||
stat("Shared", (int) entries.stream().filter(e -> Boolean.TRUE.equals(e.shared())).count(), DataAnalysisTables.SAVED),
|
||||
stat("Starred", (int) entries.stream().filter(SavedAnalyses.Entry::starred).count(), DataAnalysisTables.SAVED)));
|
||||
groups.add(saved);
|
||||
}
|
||||
groups.add(saved);
|
||||
var watches = group("Watches", DataAnalysisTables.WATCH);
|
||||
if(canRead(DataAnalysisTables.WATCH))
|
||||
{
|
||||
var watches = group("Watches");
|
||||
watches.withStatisticList(List.of(stat("Enabled", count(DataAnalysisTables.WATCH, equal("enabled", true)), DataAnalysisTables.WATCH),
|
||||
stat("Firing", count(DataAnalysisTables.WATCH, equal("state", "FIRING")), DataAnalysisTables.WATCH)));
|
||||
groups.add(watches);
|
||||
}
|
||||
groups.add(watches);
|
||||
var runs = group("My runs", DataAnalysisTables.RUN);
|
||||
if(canRead(DataAnalysisTables.RUN))
|
||||
{
|
||||
var runs = group("My runs");
|
||||
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)));
|
||||
groups.add(runs);
|
||||
}
|
||||
groups.add(runs);
|
||||
return (new MultiStatisticsData("Analyses", groups));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty/denied data are different messages, not synthetic zeroes.
|
||||
** A group is only built for a readable subject, so its subheader is always true.
|
||||
*******************************************************************************/
|
||||
private static MultiStatisticsData.StatisticsGroupData group(String label, String table)
|
||||
private static MultiStatisticsData.StatisticsGroupData group(String label)
|
||||
{
|
||||
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of())
|
||||
.withSubheader(canRead(table) ? "Visible to you" : "Unavailable: READ permission required."));
|
||||
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of()).withSubheader("Visible to you"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -227,7 +292,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
{
|
||||
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.")
|
||||
return (new TableData(label, columns, new ArrayList<>()).withNoRowsFoundHTML(empty)
|
||||
.withLinkText("View all").withLinkURL(QContext.getQInstance().getTablePath(table)).withRowsPerPage(10));
|
||||
}
|
||||
|
||||
@@ -263,7 +328,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
/*******************************************************************************
|
||||
** Explicit checks distinguish denied sections from empty sections.
|
||||
*******************************************************************************/
|
||||
private static boolean canRead(String table)
|
||||
static boolean canRead(String table)
|
||||
{
|
||||
CountInput input = new CountInput(table);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
@@ -281,7 +346,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
/*******************************************************************************
|
||||
** Counts use the viewer's session and explicitly enforce permissions.
|
||||
*******************************************************************************/
|
||||
private static int count(String table, QQueryFilter filter) throws QException
|
||||
static int count(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
CountInput input = new CountInput(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Analyses home: each configuration or content table,
|
||||
** whether it is set up, and the next step. Reads settings through the same
|
||||
** resolver the analysis runtime uses (code defaults, then rows), so the card
|
||||
** never reports something the runtime would not do. Counts run as the viewer.
|
||||
*******************************************************************************/
|
||||
public final class DataAnalysisSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: set up. DEFAULT: working on built-in defaults (nothing required).
|
||||
** OPTIONAL: a feature that stays off until someone uses it. UNAVAILABLE: the
|
||||
** viewer cannot read the table.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, DEFAULT, OPTIONAL, UNAVAILABLE, UNKNOWN
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DataAnalysisSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The card's lines, in the order a person would set things up.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load() throws QException
|
||||
{
|
||||
DataAnalysisQBitConfig config = DataAnalysisRuntime.service().getConfigResolver().getDefaults();
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
///////////////////////
|
||||
// analysis settings //
|
||||
///////////////////////
|
||||
List<String> analyzable = TableConfigDefaultsProcess.analyzableTables(QContext.getQInstance(), config);
|
||||
if(!DataAnalysisHome.canRead(DataAnalysisTables.TABLE_CONFIG))
|
||||
{
|
||||
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
int rows = DataAnalysisHome.count(DataAnalysisTables.TABLE_CONFIG, new QQueryFilter());
|
||||
String defaults = "max rows " + config.getDefaultMaxRows() + ", sample " + config.getDefaultSampleRows() + " above " + config.getDefaultSampleThresholdRows()
|
||||
+ ", smallest group " + config.getDefaultMinGroupSize() + ", results reused " + config.getDefaultCacheTtlMinutes() + " min";
|
||||
if(rows == 0)
|
||||
{
|
||||
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.DEFAULT,
|
||||
"All " + analyzable.size() + " analyzable tables are on, with the defaults (" + defaults + ").",
|
||||
"Run Create rows for analyzable tables to see and edit them, or add a row to change one table."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, rows >= analyzable.size() ? State.OK : State.DEFAULT,
|
||||
rows + " settings row" + (rows == 1 ? "" : "s") + " for " + analyzable.size() + " analyzable tables; tables without a row use the defaults (" + defaults + ").",
|
||||
rows >= analyzable.size() ? null : "Run Create rows for analyzable tables to add rows for the rest."));
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// result cache: the resolved value, exactly what caching will use //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
items.add(cacheItem(analyzable, config));
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// saved analyses, watches, schedules: optional //
|
||||
///////////////////////////////////////////////////
|
||||
items.add(optional("Saved analyses", DataAnalysisTables.SAVED, "No saved analyses yet.", "Save an analysis from the Analyze screen, then star or share it."));
|
||||
String scheduler = config.getSchedulerName() == null ? " No scheduler is configured, so watches only run with Evaluate now." : "";
|
||||
items.add(optional("Watches", DataAnalysisTables.WATCH, "No watches yet." + scheduler, "Add a watch on a saved analysis to be alerted to spikes, drops and new categories."));
|
||||
String schedules = config.getSchedulerName() == null ? " No scheduler is configured, so schedules do not run." : "";
|
||||
items.add(optional("Schedules", DataAnalysisTables.SCHEDULE, "No schedules yet." + schedules, "Add a schedule on a saved analysis to send it by email."));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One optional feature: set up once it has rows.
|
||||
*******************************************************************************/
|
||||
private static Item optional(String label, String table, String emptyDetail, String nextStep) throws QException
|
||||
{
|
||||
if(!DataAnalysisHome.canRead(table))
|
||||
{
|
||||
return (new Item(label, table, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
|
||||
}
|
||||
int rows = DataAnalysisHome.count(table, new QQueryFilter());
|
||||
return (rows == 0 ? new Item(label, table, State.OPTIONAL, emptyDetail, nextStep) : new Item(label, table, State.OK, rows + " in use.", null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Result cache line, from the minutes every analyzable table actually
|
||||
** resolves to (settings row, then table defaults, then the global default).
|
||||
*******************************************************************************/
|
||||
private static Item cacheItem(List<String> analyzable, DataAnalysisQBitConfig config)
|
||||
{
|
||||
java.util.TreeMap<Integer, Integer> tablesPerMinutes = new java.util.TreeMap<>();
|
||||
for(String table : analyzable)
|
||||
{
|
||||
try
|
||||
{
|
||||
tablesPerMinutes.merge(DataAnalysisRuntime.service().getConfigResolver().resolve(table).getCacheTtlMinutes(), 1, Integer::sum);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (new Item("Result cache", DataAnalysisTables.RUN, State.UNKNOWN, "The cache settings of " + table + " could not be read: " + e.getMessage(),
|
||||
"Fix the settings row for that table (a negative limit is invalid)."));
|
||||
}
|
||||
}
|
||||
if(tablesPerMinutes.isEmpty() || (tablesPerMinutes.size() == 1 && tablesPerMinutes.firstKey() == 0))
|
||||
{
|
||||
return (new Item("Result cache", DataAnalysisTables.RUN, State.DEFAULT, "Off: every exact run computes fresh.", "Set cache minutes on Analysis Settings to reuse results."));
|
||||
}
|
||||
if(tablesPerMinutes.size() == 1)
|
||||
{
|
||||
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused for " + tablesPerMinutes.firstKey() + " minutes.", null));
|
||||
}
|
||||
String parts = tablesPerMinutes.entrySet().stream().map(e -> e.getValue() + (e.getValue() == 1 ? " table" : " tables") + (e.getKey() == 0 ? ": off" : ": " + e.getKey() + " min"))
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused per table (default " + config.getDefaultCacheTtlMinutes() + " min): " + parts + ".", null));
|
||||
}
|
||||
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A widget is available only to sessions that can READ the table it summarizes.
|
||||
** Contributes no catalog entry of its own: the table's READ permission is the
|
||||
** permission.
|
||||
*******************************************************************************/
|
||||
public class TableReadWidgetPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
|
||||
{
|
||||
private static final String TABLE_NAME_PROPERTY = "tableName";
|
||||
|
||||
private String tableName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Code reference for the given table.
|
||||
*******************************************************************************/
|
||||
public static QCodeReferenceWithProperties build(String tableName)
|
||||
{
|
||||
return (new QCodeReferenceWithProperties(TableReadWidgetPermissionChecker.class, MapBuilder.of(TABLE_NAME_PROPERTY, tableName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the table name from the code reference properties.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
|
||||
{
|
||||
Map<String, Serializable> properties = withProperties.getProperties();
|
||||
tableName = ValueUtils.getValueAsString(properties.get(TABLE_NAME_PROPERTY));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deny unless the session may READ the table.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
AbstractTableActionInput tableInput = new AbstractTableActionInput();
|
||||
tableInput.setTableName(tableName);
|
||||
PermissionsHelper.checkTablePermissionThrowing(tableInput, TablePermissionSubType.READ);
|
||||
}
|
||||
}
|
||||
@@ -302,9 +302,19 @@ public final class DrillFilters
|
||||
|
||||
/*******************************************************************************
|
||||
** String decimal operands preserve exact scale and precision across JSON round trips.
|
||||
** Filter expressions (relative dates) are left for core to evaluate.
|
||||
*******************************************************************************/
|
||||
private static Serializable restoreValue(com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType type, Serializable value)
|
||||
{
|
||||
if(value instanceof com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.AbstractFilterExpression<?>)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// a relative date ("now minus one day", "this month") is not a value to //
|
||||
// convert: core evaluates it when the query runs, so a saved or //
|
||||
// scheduled analysis means "the last day" on every run //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
return (value);
|
||||
}
|
||||
if(type == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.DECIMAL)
|
||||
{
|
||||
return (value == null ? null : value.toString());
|
||||
|
||||
@@ -65,6 +65,16 @@ public class EffectiveConfigResolver
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The code configuration this resolver starts from.
|
||||
*******************************************************************************/
|
||||
public DataAnalysisQBitConfig getDefaults()
|
||||
{
|
||||
return (defaults);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return a detached snapshot so callers cannot change memoized policy.
|
||||
*******************************************************************************/
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ public class DataAnalysisMetaDataProducer extends QBitComponentMetaDataProducer<
|
||||
DataAnalysisTables.joins().forEach(output::add);
|
||||
DataAnalysisTables.childListWidgets().forEach(output::add);
|
||||
output.add(DataAnalysisTables.userKeyType());
|
||||
output.add(com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess.process());
|
||||
List<QTableMetaData> tables = DataAnalysisTables.tables(getQBitConfig());
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Create rows for analyzable tables": a table action on Analysis Settings
|
||||
** that writes one row per analyzable table that has none yet, holding the
|
||||
** settings that already apply to it (code defaults for that table, then the
|
||||
** global defaults). It changes no behavior on its own: it makes the defaults
|
||||
** visible and editable. Existing rows are never touched. Standard streamed
|
||||
** ETL: Review lists what will be added, Result what was added.
|
||||
*******************************************************************************/
|
||||
public final class TableConfigDefaultsProcess
|
||||
{
|
||||
public static final String NAME = "data_analysis_table_config_create_rows";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private TableConfigDefaultsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action (no records need to be selected). Only
|
||||
** people who may insert Analysis Settings see it; the steps enforce the table
|
||||
** permissions again.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, DataAnalysisTables.TABLE_CONFIG);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, DataAnalysisTables.TABLE_CONFIG);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"These rows will be added: one per analyzable table that has none, holding the settings that already apply. Existing rows are not changed.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Create rows for analyzable tables");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName(DataAnalysisTables.TABLE_CONFIG);
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(DataAnalysisTables.TABLE_CONFIG, TablePermissionSubType.INSERT)));
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables analysis may be used on: everything but the qbit's own tables,
|
||||
** limited by the includeTables and excludeTables configuration. The same test
|
||||
** the "analyze" table permission uses.
|
||||
*******************************************************************************/
|
||||
public static List<String> analyzableTables(QInstance instance, DataAnalysisQBitConfig config)
|
||||
{
|
||||
return (instance.getTables().values().stream().map(QTableMetaData::getName)
|
||||
.filter(name -> !name.startsWith(DataAnalysisQBitConfig.TABLE_NAME_PREFIX))
|
||||
.filter(name -> config.getIncludeTables().isEmpty() || config.getIncludeTables().contains(name))
|
||||
.filter(name -> !config.getExcludeTables().contains(name)).sorted().toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rows that are missing: one per analyzable table without a row, with
|
||||
** the effective settings (the same values EffectiveConfigResolver produces).
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> missingRows(QInstance instance, DataAnalysisQBitConfig config) throws QException
|
||||
{
|
||||
Set<String> existing = existingTables();
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(String table : analyzableTables(instance, config))
|
||||
{
|
||||
if(!existing.contains(table))
|
||||
{
|
||||
EffectiveConfig c = DataAnalysisRuntime.service().getConfigResolver().resolve(table);
|
||||
rows.add(new QRecord().withValue("table_name", table).withValue("enabled", c.getEnabled()).withValue("max_rows", c.getMaxRows())
|
||||
.withValue("sample_threshold_rows", c.getSampleThresholdRows()).withValue("sample_rows", c.getSampleRows())
|
||||
.withValue("min_group_size", c.getMinGroupSize()).withValue("cache_ttl_minutes", c.getCacheTtlMinutes())
|
||||
.withValue("analyzable_fields", c.getAnalyzableFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getAnalyzableFields())))
|
||||
.withValue("excluded_fields", c.getExcludedFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getExcludedFields()))));
|
||||
}
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table names that already have a settings row (read as the signed-in user).
|
||||
*******************************************************************************/
|
||||
private static Set<String> existingTables() throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG).withFilter(new QQueryFilter());
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
Set<String> existing = new HashSet<>();
|
||||
new QueryAction().execute(input).getRecords().forEach(row -> existing.add(row.getValueString("table_name")));
|
||||
return (existing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the missing rows, computed (no query of the selected records).
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()).size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()))
|
||||
{
|
||||
getRecordPipe().addRecord(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform: lists what will be added for the review screen.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine added = line(Status.OK, "table will get a settings row", "tables will get a settings row",
|
||||
"table got a settings row", "tables got a settings row");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
added.incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
added.addSelfToListIfAnyCount(lines);
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every analyzable table already has a settings row."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert through the standard action with the user's own permissions.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine created = line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
InsertInput insert = new InsertInput(DataAnalysisTables.TABLE_CONFIG).withRecords(input.getRecords());
|
||||
insert.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
for(QRecord record : new InsertAction().execute(insert).getRecords())
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not add analysis settings: " + record.getErrors()));
|
||||
}
|
||||
created.incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
created.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with its four messages.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(singularFuture);
|
||||
line.setPluralFutureMessage(pluralFuture);
|
||||
line.setSingularPastMessage(singularPast);
|
||||
line.setPluralPastMessage(pluralPast);
|
||||
return (line);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis.schedule;
|
||||
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sessions for scheduled analysis work. With the host's data-analysis service
|
||||
** principal declared (QQQ service identities): inventory runs as the
|
||||
** principal, and owner or recipient runs go on their behalf through it (their
|
||||
** current access intersected with the principal's). Without it: the previous
|
||||
** local sessions (single-node default unchanged). Never a system session.
|
||||
*******************************************************************************/
|
||||
public final class AnalysisSessions
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AnalysisSessions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The session for schedule/watch inventory and status updates.
|
||||
*******************************************************************************/
|
||||
public static QSession maintenance(DataAnalysisQBitConfig config, Supplier<QSession> legacy) throws QException
|
||||
{
|
||||
if(usesPrincipal(config))
|
||||
{
|
||||
return (ServiceIdentities.session(QContext.getQInstance(), config.getServicePrincipal()));
|
||||
}
|
||||
return (legacy.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The session for running as a user (owner or recipient).
|
||||
*******************************************************************************/
|
||||
public static QSession forUser(DataAnalysisQBitConfig config, String userId) throws QException
|
||||
{
|
||||
if(config.getOwnerSessionSupplier() != null)
|
||||
{
|
||||
return (config.getOwnerSessionSupplier().apply(userId));
|
||||
}
|
||||
if(usesPrincipal(config))
|
||||
{
|
||||
return (ServiceIdentities.sessionOnBehalfOf(QContext.getQInstance(), config.getServicePrincipal(), userId));
|
||||
}
|
||||
return (new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the host declared the configured principal.
|
||||
*******************************************************************************/
|
||||
private static boolean usesPrincipal(DataAnalysisQBitConfig config)
|
||||
{
|
||||
return (config.getServicePrincipal() != null && ServiceIdentities.isDeclared(QContext.getQInstance(), config.getServicePrincipal()));
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled work uses owner and recipient sessions; maintenance only inventories rows.
|
||||
@@ -83,13 +82,17 @@ public class ScheduleRunner
|
||||
*******************************************************************************/
|
||||
public synchronized Integer runDue(Instant now) throws QException
|
||||
{
|
||||
QSession maintenance = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
|
||||
.withPermissions("data_analysis.saved.read", "data_analysis.saved.edit")
|
||||
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
|
||||
if(QContext.getQSession().getBackendVariants() != null)
|
||||
QSession maintenance = AnalysisSessions.maintenance(config, () ->
|
||||
{
|
||||
maintenance.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
|
||||
}
|
||||
QSession legacy = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
|
||||
.withPermissions("data_analysis.saved.read", "data_analysis.saved.edit")
|
||||
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
|
||||
if(QContext.getQSession().getBackendVariants() != null)
|
||||
{
|
||||
legacy.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
|
||||
}
|
||||
return (legacy);
|
||||
});
|
||||
int[] count = {0};
|
||||
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), maintenance), () ->
|
||||
{
|
||||
@@ -161,9 +164,7 @@ public class ScheduleRunner
|
||||
QSession session;
|
||||
try
|
||||
{
|
||||
session = config.getOwnerSessionSupplier() == null
|
||||
? new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), id)
|
||||
: config.getOwnerSessionSupplier().apply(id);
|
||||
session = AnalysisSessions.forUser(config, id);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
|
||||
@@ -49,7 +49,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
|
||||
/*******************************************************************************
|
||||
** Transition watches through the core alerts SPI, computing exclusively as owner.
|
||||
@@ -94,13 +93,17 @@ public class WatchEvaluator
|
||||
*******************************************************************************/
|
||||
public synchronized Integer evaluateEnabled(Instant now) throws QException
|
||||
{
|
||||
QSession maintenance = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
|
||||
.withPermissions("data_analysis.watch.read", "data_analysis.watch.edit")
|
||||
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
|
||||
if(QContext.getQSession().getBackendVariants() != null)
|
||||
QSession maintenance = com.kingsrook.qbits.dataanalysis.schedule.AnalysisSessions.maintenance(config, () ->
|
||||
{
|
||||
maintenance.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
|
||||
}
|
||||
QSession legacy = new QSession().withUser(new QUser().withIdReference("data-analysis-scheduler"))
|
||||
.withPermissions("data_analysis.watch.read", "data_analysis.watch.edit")
|
||||
.withSecurityKeyValue(DataAnalysisPermissions.ADMIN, true);
|
||||
if(QContext.getQSession().getBackendVariants() != null)
|
||||
{
|
||||
legacy.setBackendVariants(new java.util.HashMap<>(QContext.getQSession().getBackendVariants()));
|
||||
}
|
||||
return (legacy);
|
||||
});
|
||||
int[] count = {0};
|
||||
QContext.withTemporaryContext(new CapturedContext(QContext.getQInstance(), maintenance), () ->
|
||||
{
|
||||
@@ -128,9 +131,7 @@ public class WatchEvaluator
|
||||
QSession session;
|
||||
try
|
||||
{
|
||||
session = config.getOwnerSessionSupplier() == null
|
||||
? new QAuthenticationModuleDispatcher().getQModule(QContext.getQInstance().getAuthentication()).createAutomatedSessionForUser(QContext.getQInstance(), owner)
|
||||
: config.getOwnerSessionSupplier().apply(owner);
|
||||
session = com.kingsrook.qbits.dataanalysis.schedule.AnalysisSessions.forUser(config, owner);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
bundle: qbit-data-analysis
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: widget:data_analysis_saved_to_data_analysis_saved_star;slot:label
|
||||
summary: "See stars associated with this saved analysis."
|
||||
- key: widget:data_analysis_saved_to_data_analysis_schedule;slot:label
|
||||
summary: "Review delivery schedules for this saved analysis."
|
||||
- key: widget:data_analysis_saved_to_data_analysis_watch;slot:label
|
||||
summary: "Review watches associated with this saved analysis."
|
||||
- key: widget:dataAnalysisHome;slot:label
|
||||
summary: "See an overview of saved analyses, watches and runs you can access."
|
||||
- key: widget:dataAnalysisStarred;slot:label
|
||||
summary: "Open saved analyses you have starred."
|
||||
- key: widget:dataAnalysisFiring;slot:label
|
||||
summary: "Review watches that are currently firing."
|
||||
- key: widget:dataAnalysisRecent;slot:label
|
||||
summary: "Review your recent analysis runs."
|
||||
- key: widget:DATA_ANALYSIS_SAVED;slot:label
|
||||
summary: "View the chart produced by a saved analysis."
|
||||
- key: app:dataAnalysis;slot:help
|
||||
summary: "Explore your data, revisit saved analyses and monitor changes with watches."
|
||||
content: |
|
||||
Use Analyses to return to saved work, view starred analyses and review watches that are firing. Analyze a table to explore its records, then save the choices you want to revisit. Analysis Settings controls availability and limits for each table. Analysis Runs helps you review exact results and investigate failures.
|
||||
- key: table:data_analysis_table_config;slot:help
|
||||
summary: "Control analysis availability, row limits, sampling and result reuse for each table."
|
||||
content: |
|
||||
**How rows get created:** Run **Create rows for analyzable tables** to add the effective settings for tables that do not have a row, or choose New for one table. Existing rows are left unchanged. Without a row, your configured defaults apply.
|
||||
|
||||
Use Analysis Settings to adjust how a table can be analyzed. You can enable analysis, set row and sample limits, choose allowed or excluded fields, and hide groups smaller than a chosen size. Cache Ttl Minutes controls how long completed exact results can be reused. Tables without an entry use the configured defaults.
|
||||
- key: table:data_analysis_table_config;field:table_name
|
||||
summary: "The table whose records you want to analyze."
|
||||
- key: table:data_analysis_table_config;field:enabled
|
||||
summary: "Whether you can analyze records from this table."
|
||||
- key: table:data_analysis_table_config;field:max_rows
|
||||
summary: "The row limit used when analysis reads individual records."
|
||||
- key: table:data_analysis_table_config;field:sample_threshold_rows
|
||||
summary: "The row count above which an estimated analysis may use a sample."
|
||||
- key: table:data_analysis_table_config;field:sample_rows
|
||||
summary: "The number of rows to include in a sample."
|
||||
- key: table:data_analysis_table_config;field:min_group_size
|
||||
summary: "The smallest group of records that may appear in results."
|
||||
content: |
|
||||
Min Group Size prevents very small groups from appearing in analysis results. Choose the minimum number of records a group must contain before it can be shown. If expected categories are missing, review this limit along with your filters before assuming that the underlying records are absent.
|
||||
- key: table:data_analysis_table_config;field:analyzable_fields
|
||||
summary: "The fields allowed to be analyzed for this table."
|
||||
- key: table:data_analysis_table_config;field:excluded_fields
|
||||
summary: "The fields excluded from analysis for this table."
|
||||
- key: table:data_analysis_table_config;field:cache_ttl_minutes
|
||||
summary: "How many minutes completed exact results can be reused; zero turns reuse off."
|
||||
- key: table:data_analysis_saved;slot:help
|
||||
summary: "Return to analyses you saved with their table, filters, view and chart choices."
|
||||
content: |
|
||||
**How rows get created:** Save your analysis choices to create a saved analysis. An empty list means you have no saved analyses available to you.
|
||||
|
||||
Save an analysis from Analyze to keep the table, filters and analysis choices you want to revisit. Each saved analysis has an owner and can be starred for quick access. Open its Schedules and Watches lists to review related deliveries and checks. Sharing the definition does not remove access restrictions on the underlying data.
|
||||
- key: table:data_analysis_saved;field:name
|
||||
summary: "The name you use to find this saved analysis."
|
||||
- key: table:data_analysis_saved;field:description
|
||||
summary: "A description that helps you understand this entry."
|
||||
- key: table:data_analysis_saved;field:table_name
|
||||
summary: "The table whose records you want to analyze."
|
||||
- key: table:data_analysis_saved;field:filter_json
|
||||
summary: "The saved record filters, stored as JSON."
|
||||
- key: table:data_analysis_saved;field:kind
|
||||
summary: "The type of analysis, such as a profile, pivot or comparison."
|
||||
- key: table:data_analysis_saved;field:definition_json
|
||||
summary: "The saved analysis choices, stored as JSON."
|
||||
- key: table:data_analysis_saved;field:chart_kind
|
||||
summary: "The chart style used to display the analysis."
|
||||
- key: table:data_analysis_saved;field:owner_user_id
|
||||
summary: "The person who owns and manages this analysis or watch."
|
||||
- key: table:data_analysis_saved;field:shared
|
||||
summary: "Whether other permitted people can read the saved analysis definition."
|
||||
- key: table:data_analysis_saved_star;slot:help
|
||||
summary: "Keep track of the saved analyses you have starred."
|
||||
content: |
|
||||
**How rows get created:** Star a saved analysis to create your personal star. An empty list means you have no stars available here.
|
||||
|
||||
Star a saved analysis to make it easier to find on the Analyses home. Each star links one person to one saved analysis, so your starred list is personal. These entries are created through the star control. An empty list means you have not starred an analysis available here.
|
||||
- key: table:data_analysis_saved_star;field:saved_id
|
||||
summary: "The saved analysis this entry belongs to."
|
||||
- key: table:data_analysis_saved_star;field:user_id
|
||||
summary: "The person this entry belongs to."
|
||||
- key: table:data_analysis_schedule;slot:help
|
||||
summary: "Run saved analyses at chosen times and deliver their results to selected people."
|
||||
content: |
|
||||
**How rows get created:** Add a schedule to a saved analysis to create a delivery definition. An empty list means you have no schedules available here.
|
||||
|
||||
Add a schedule to a saved analysis to send results daily, weekly or monthly. Choose the local time, time zone, recipients and output format, then enable it when ready. Automatic runs require scheduling to be configured. Use Send now for an immediate delivery and review Last Status for the outcome.
|
||||
- key: table:data_analysis_schedule;field:saved_id
|
||||
summary: "The saved analysis this entry belongs to."
|
||||
- key: table:data_analysis_schedule;field:cron
|
||||
summary: "An advanced schedule expression that takes precedence over Frequency, Day and Time."
|
||||
content: |
|
||||
Leave Cron blank when you want to schedule deliveries with Frequency, Day and Time. If you supply an advanced expression, it uses the schedule's Time Zone and replaces those calendar choices. You must use a valid Quartz schedule expression. Check Next Run At after saving to confirm the resulting delivery time.
|
||||
- key: table:data_analysis_schedule;field:frequency
|
||||
summary: "Choose daily, weekly or monthly delivery."
|
||||
- key: table:data_analysis_schedule;field:day
|
||||
summary: "The weekday number or day of the month used by the schedule."
|
||||
content: |
|
||||
For a weekly schedule, enter a weekday number from 1 for Monday through 7 for Sunday. For a monthly schedule, enter a day from 1 through 31. If that day does not exist in a month, delivery uses the last day of that month. Daily schedules do not need a day.
|
||||
- key: table:data_analysis_schedule;field:time
|
||||
summary: "The local time of day when the analysis should run."
|
||||
- key: table:data_analysis_schedule;field:time_zone
|
||||
summary: "The time zone used to interpret the scheduled time."
|
||||
- key: table:data_analysis_schedule;field:format
|
||||
summary: "Choose an HTML summary, CSV file or Excel workbook for delivery."
|
||||
- key: table:data_analysis_schedule;field:recipients
|
||||
summary: "The people receiving the result, listed by user ID in a JSON array."
|
||||
content: |
|
||||
Recipients accepts a list of user IDs, not email addresses. Supply a JSON array containing between one and one hundred nonblank IDs. Duplicate IDs are treated as one recipient. Review the people in the list before you enable the schedule or choose Send now, since those actions deliver the analysis result.
|
||||
- key: table:data_analysis_schedule;field:enabled
|
||||
summary: "Whether the schedule is allowed to run automatically."
|
||||
- key: table:data_analysis_schedule;field:last_run_at
|
||||
summary: "When the schedule last ran."
|
||||
- key: table:data_analysis_schedule;field:last_status
|
||||
summary: "The outcome of the most recent scheduled delivery."
|
||||
- key: table:data_analysis_schedule;field:next_run_at
|
||||
summary: "When the schedule is next due to run."
|
||||
- key: table:data_analysis_watch;slot:help
|
||||
summary: "Check analysis results for spikes, drops, new categories or other selected conditions."
|
||||
content: |
|
||||
**How rows get created:** Add a watch for a saved analysis or a table and metric. An empty list means you have no watches available here.
|
||||
|
||||
Create a watch to check a saved analysis or a table and metric you specify. Choose the detector, its parameters, the date field and time grouping. Enabled watches are checked automatically when scheduling is configured, and you can use Evaluate now to check immediately. Review State and Last Message to understand the result.
|
||||
- key: table:data_analysis_watch;field:saved_id
|
||||
summary: "The saved analysis this entry belongs to."
|
||||
- key: table:data_analysis_watch;field:table_name
|
||||
summary: "The table whose records you want to analyze."
|
||||
- key: table:data_analysis_watch;field:filter_json
|
||||
summary: "The saved record filters, stored as JSON."
|
||||
- key: table:data_analysis_watch;field:metric_json
|
||||
summary: "The measurement checked by the watch, stored as JSON."
|
||||
- key: table:data_analysis_watch;field:detector
|
||||
summary: "The condition to detect, such as a spike, drop or new category."
|
||||
- key: table:data_analysis_watch;field:parameters_json
|
||||
summary: "The settings used by the selected detector, stored as JSON."
|
||||
- key: table:data_analysis_watch;field:date_field
|
||||
summary: "The date field used to place records into time groups."
|
||||
- key: table:data_analysis_watch;field:bucket
|
||||
summary: "The time interval used to group records for the watch."
|
||||
- key: table:data_analysis_watch;field:severity
|
||||
summary: "The importance assigned to an alert raised by the watch."
|
||||
- key: table:data_analysis_watch;field:owner_user_id
|
||||
summary: "The person who owns and manages this analysis or watch."
|
||||
- key: table:data_analysis_watch;field:enabled
|
||||
summary: "Whether the watch is included in automatic checks."
|
||||
- key: table:data_analysis_watch;field:last_evaluated_at
|
||||
summary: "When the watch was last checked."
|
||||
- key: table:data_analysis_watch;field:last_value
|
||||
summary: "The latest measured value from the watch."
|
||||
- key: table:data_analysis_watch;field:last_message
|
||||
summary: "The latest explanation of the watch result."
|
||||
- key: table:data_analysis_watch;field:state
|
||||
summary: "Whether the watch is currently OK or firing."
|
||||
- key: table:data_analysis_watch;field:alert_key
|
||||
summary: "The identifier used to associate the watch with its alert."
|
||||
- key: table:data_analysis_run;slot:help
|
||||
summary: "Review exact analysis runs, their results, timing and any errors."
|
||||
content: |
|
||||
**How rows get created:** Start an exact analysis to record its execution and reusable result. An empty list means you have no recorded runs available here.
|
||||
|
||||
Analysis Runs records exact analyses as they run. You can review who requested an analysis, its table and operation, the number of rows scanned and its status. Completed results can be reused for the time allowed in Analysis Settings. If a run fails, read Error before trying the analysis again.
|
||||
- key: table:data_analysis_run;field:table_name
|
||||
summary: "The table whose records you want to analyze."
|
||||
- key: table:data_analysis_run;field:operation
|
||||
summary: "The analysis operation performed by the run."
|
||||
- key: table:data_analysis_run;field:request_hash
|
||||
summary: "A fingerprint of the analysis request used to match reusable results."
|
||||
- key: table:data_analysis_run;field:filter_hash
|
||||
summary: "A fingerprint of the filters used to match reusable results."
|
||||
- key: table:data_analysis_run;field:data_version
|
||||
summary: "The data version associated with the stored result."
|
||||
- key: table:data_analysis_run;field:status
|
||||
summary: "Whether the analysis is running, complete or failed."
|
||||
- key: table:data_analysis_run;field:started_at
|
||||
summary: "When the analysis run started."
|
||||
- key: table:data_analysis_run;field:finished_at
|
||||
summary: "When the analysis run finished."
|
||||
- key: table:data_analysis_run;field:rows_scanned
|
||||
summary: "The number of rows examined by the analysis."
|
||||
- key: table:data_analysis_run;field:sampled
|
||||
summary: "Whether the result was calculated from a sample of rows."
|
||||
- key: table:data_analysis_run;field:result_json
|
||||
summary: "The stored analysis result in JSON form."
|
||||
- key: table:data_analysis_run;field:error
|
||||
summary: "The reason the analysis run failed."
|
||||
- key: table:data_analysis_run;field:user_id
|
||||
summary: "The person this entry belongs to."
|
||||
- key: process:data_analysis_schedule_send;slot:help
|
||||
summary: "Run selected analysis schedules and send their results now."
|
||||
content: |
|
||||
Use Send now on selected Schedules when you need a delivery without waiting for the next scheduled time. Review the schedules before continuing. The action uses each schedule’s saved analysis, recipients and format. Check the result and Last Status afterward to confirm which deliveries completed or failed.
|
||||
- key: process:data_analysis_run_schedules;slot:help
|
||||
summary: "Run analysis schedules whose next delivery time is due."
|
||||
content: |
|
||||
Run analysis schedules is the background action that checks for scheduled deliveries. Configure and enable schedules on a saved analysis to use automatic delivery. You can inspect Next Run At, Last Run At and Last Status on Schedules to understand timing and outcomes. Automatic execution requires a configured scheduler.
|
||||
- key: process:data_analysis_watch_evaluate;slot:help
|
||||
summary: "Check selected watches now and update their results."
|
||||
content: |
|
||||
Use Evaluate now to check selected Watches without waiting for the next automatic check. Review the selection before continuing. After the action, inspect State, Last Value and Last Message to understand each result. A watch that detects its configured condition can raise an alert with the selected severity.
|
||||
- key: process:data_analysis_watch_enable;slot:help
|
||||
summary: "Include selected watches in automatic checks."
|
||||
content: |
|
||||
Use Enable when selected Watches are ready for automatic checking. Review the detector, parameters, date field and time grouping before enabling them. The action checks your access to each watch and reports the outcome. Automatic checks require a configured scheduler; use Evaluate now when you need an immediate result.
|
||||
- key: process:data_analysis_watch_disable;slot:help
|
||||
summary: "Exclude selected watches from automatic checks."
|
||||
content: |
|
||||
Use Disable when you want selected Watches to stop participating in automatic checks. Review the selection before continuing and read the result for each watch. The watch definition remains available for inspection and later use. Use Enable when you are ready to include it in automatic checks again.
|
||||
- key: process:data_analysis_evaluate_watches;slot:help
|
||||
summary: "Check enabled watches and update their alert conditions."
|
||||
content: |
|
||||
Evaluate watches is the background action that checks enabled Watches. With scheduling configured, it runs every fifteen minutes. You can review Last Evaluated At, State and Last Message to see what happened. Use Evaluate now on a watch when you need to check its condition before the next scheduled run.
|
||||
- key: table:data_analysis_table_config;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:data_analysis_table_config;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:data_analysis_table_config;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:data_analysis_saved;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:data_analysis_saved;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:data_analysis_saved;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:data_analysis_saved_star;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:data_analysis_saved_star;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:data_analysis_schedule;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:data_analysis_schedule;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:data_analysis_schedule;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:data_analysis_watch;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:data_analysis_watch;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:data_analysis_watch;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:data_analysis_run;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:data_analysis_run;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:data_analysis_run;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:data_analysis_saved;section:data_analysis_saved_to_data_analysis_saved_star
|
||||
summary: "People who starred this saved analysis."
|
||||
- key: table:data_analysis_saved;section:data_analysis_saved_to_data_analysis_schedule
|
||||
summary: "Delivery schedules associated with this saved analysis."
|
||||
- key: table:data_analysis_saved;section:data_analysis_saved_to_data_analysis_watch
|
||||
summary: "Watches associated with this saved analysis."
|
||||
- key: widget:dataAnalysisSetup;slot:label
|
||||
summary: "Review analysis setup and the next steps available to you."
|
||||
- key: process:data_analysis_table_config_create_rows;slot:help
|
||||
summary: "Create editable settings rows for analyzable tables that do not already have one."
|
||||
content: |
|
||||
Use **Create rows for analyzable tables** in Analysis Settings to copy the effective settings into new rows.
|
||||
Review the tables before continuing. Existing settings rows are left unchanged.
|
||||
You need permission to insert settings. Your analysis behavior stays the same until you edit a setting.
|
||||
- key: possibleValueSource:data_analysis_kind;slot:help
|
||||
summary: "Choose from the available analysis types."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_kind}}.
|
||||
- key: possibleValueSource:data_analysis_chart_kind;slot:help
|
||||
summary: "Choose from the available chart styles."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_chart_kind}}.
|
||||
- key: possibleValueSource:data_analysis_detector;slot:help
|
||||
summary: "Choose from the available watch detectors."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_detector}}.
|
||||
- key: possibleValueSource:data_analysis_watch_state;slot:help
|
||||
summary: "Choose from the available watch states."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_watch_state}}.
|
||||
- key: possibleValueSource:data_analysis_watch_bucket;slot:help
|
||||
summary: "Choose from the available time intervals."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_watch_bucket}}.
|
||||
- key: possibleValueSource:data_analysis_watch_severity;slot:help
|
||||
summary: "Choose from the available alert severities."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_watch_severity}}.
|
||||
- key: possibleValueSource:data_analysis_run_status;slot:help
|
||||
summary: "Choose from the available run states."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_run_status}}.
|
||||
- key: possibleValueSource:data_analysis_schedule_frequency;slot:help
|
||||
summary: "Choose from the available delivery frequencies."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_schedule_frequency}}.
|
||||
- key: possibleValueSource:data_analysis_schedule_format;slot:help
|
||||
summary: "Choose from the available delivery formats."
|
||||
content: |
|
||||
Your available choices are {{values:data_analysis_schedule_format}}.
|
||||
- key: possibleValueSource:data_analysis_table_config;slot:help
|
||||
summary: "Select a record from {{label:data_analysis_table_config}}."
|
||||
- key: possibleValueSource:data_analysis_saved;slot:help
|
||||
summary: "Select a record from {{label:data_analysis_saved}}."
|
||||
- key: possibleValueSource:data_analysis_saved_star;slot:help
|
||||
summary: "Select a record from {{label:data_analysis_saved_star}}."
|
||||
- key: possibleValueSource:data_analysis_schedule;slot:help
|
||||
summary: "Select a record from {{label:data_analysis_schedule}}."
|
||||
- key: possibleValueSource:data_analysis_watch;slot:help
|
||||
summary: "Select a record from {{label:data_analysis_watch}}."
|
||||
- key: possibleValueSource:data_analysis_run;slot:help
|
||||
summary: "Select a record from {{label:data_analysis_run}}."
|
||||
- key: possibleValueSource:data_analysis_fields;slot:help
|
||||
summary: "Choose fields from the selected table for your analysis settings."
|
||||
- key: process:data_analysis_table_config.bulkInsert;slot:help
|
||||
summary: "Import new records from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_table_config.bulkInsert}}** to add records to {{label:data_analysis_table_config}} from a file. Match your file columns to the available fields and review the input before continuing. Check the result for validation errors and records you need to correct.
|
||||
- key: process:data_analysis_table_config.bulkEdit;slot:help
|
||||
summary: "Apply changes to multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_table_config.bulkEdit}}** to change fields on your selected records in {{label:data_analysis_table_config}}. Review your selection and the values you want to change before continuing. Check the result for records that could not be updated.
|
||||
- key: process:data_analysis_table_config.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_table_config.bulkEditWithFile}}** to update records in {{label:data_analysis_table_config}}. Match your file columns to fields and identify the existing records to update. Review the input before continuing and check the result for errors.
|
||||
- key: process:data_analysis_table_config.bulkDelete;slot:help
|
||||
summary: "Delete multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_table_config.bulkDelete}}** to remove selected records from {{label:data_analysis_table_config}}. Review your selection carefully before confirming deletion. Check the result for records that could not be deleted.
|
||||
- key: process:data_analysis_saved.bulkInsert;slot:help
|
||||
summary: "Import new records from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved.bulkInsert}}** to add records to {{label:data_analysis_saved}} from a file. Match your file columns to the available fields and review the input before continuing. Check the result for validation errors and records you need to correct.
|
||||
- key: process:data_analysis_saved.bulkEdit;slot:help
|
||||
summary: "Apply changes to multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved.bulkEdit}}** to change fields on your selected records in {{label:data_analysis_saved}}. Review your selection and the values you want to change before continuing. Check the result for records that could not be updated.
|
||||
- key: process:data_analysis_saved.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved.bulkEditWithFile}}** to update records in {{label:data_analysis_saved}}. Match your file columns to fields and identify the existing records to update. Review the input before continuing and check the result for errors.
|
||||
- key: process:data_analysis_saved.bulkDelete;slot:help
|
||||
summary: "Delete multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved.bulkDelete}}** to remove selected records from {{label:data_analysis_saved}}. Review your selection carefully before confirming deletion. Check the result for records that could not be deleted.
|
||||
- key: process:data_analysis_saved_star.bulkInsert;slot:help
|
||||
summary: "Import new records from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved_star.bulkInsert}}** to add records to {{label:data_analysis_saved_star}} from a file. Match your file columns to the available fields and review the input before continuing. Check the result for validation errors and records you need to correct.
|
||||
- key: process:data_analysis_saved_star.bulkEdit;slot:help
|
||||
summary: "Apply changes to multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved_star.bulkEdit}}** to change fields on your selected records in {{label:data_analysis_saved_star}}. Review your selection and the values you want to change before continuing. Check the result for records that could not be updated.
|
||||
- key: process:data_analysis_saved_star.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved_star.bulkEditWithFile}}** to update records in {{label:data_analysis_saved_star}}. Match your file columns to fields and identify the existing records to update. Review the input before continuing and check the result for errors.
|
||||
- key: process:data_analysis_saved_star.bulkDelete;slot:help
|
||||
summary: "Delete multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_saved_star.bulkDelete}}** to remove selected records from {{label:data_analysis_saved_star}}. Review your selection carefully before confirming deletion. Check the result for records that could not be deleted.
|
||||
- key: process:data_analysis_schedule.bulkInsert;slot:help
|
||||
summary: "Import new records from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_schedule.bulkInsert}}** to add records to {{label:data_analysis_schedule}} from a file. Match your file columns to the available fields and review the input before continuing. Check the result for validation errors and records you need to correct.
|
||||
- key: process:data_analysis_schedule.bulkEdit;slot:help
|
||||
summary: "Apply changes to multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_schedule.bulkEdit}}** to change fields on your selected records in {{label:data_analysis_schedule}}. Review your selection and the values you want to change before continuing. Check the result for records that could not be updated.
|
||||
- key: process:data_analysis_schedule.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_schedule.bulkEditWithFile}}** to update records in {{label:data_analysis_schedule}}. Match your file columns to fields and identify the existing records to update. Review the input before continuing and check the result for errors.
|
||||
- key: process:data_analysis_schedule.bulkDelete;slot:help
|
||||
summary: "Delete multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_schedule.bulkDelete}}** to remove selected records from {{label:data_analysis_schedule}}. Review your selection carefully before confirming deletion. Check the result for records that could not be deleted.
|
||||
- key: process:data_analysis_watch.bulkInsert;slot:help
|
||||
summary: "Import new records from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_watch.bulkInsert}}** to add records to {{label:data_analysis_watch}} from a file. Match your file columns to the available fields and review the input before continuing. Check the result for validation errors and records you need to correct.
|
||||
- key: process:data_analysis_watch.bulkEdit;slot:help
|
||||
summary: "Apply changes to multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_watch.bulkEdit}}** to change fields on your selected records in {{label:data_analysis_watch}}. Review your selection and the values you want to change before continuing. Check the result for records that could not be updated.
|
||||
- key: process:data_analysis_watch.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_watch.bulkEditWithFile}}** to update records in {{label:data_analysis_watch}}. Match your file columns to fields and identify the existing records to update. Review the input before continuing and check the result for errors.
|
||||
- key: process:data_analysis_watch.bulkDelete;slot:help
|
||||
summary: "Delete multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_watch.bulkDelete}}** to remove selected records from {{label:data_analysis_watch}}. Review your selection carefully before confirming deletion. Check the result for records that could not be deleted.
|
||||
- key: process:data_analysis_run.bulkInsert;slot:help
|
||||
summary: "Import new records from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_run.bulkInsert}}** to add records to {{label:data_analysis_run}} from a file. Match your file columns to the available fields and review the input before continuing. Check the result for validation errors and records you need to correct.
|
||||
- key: process:data_analysis_run.bulkEdit;slot:help
|
||||
summary: "Apply changes to multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_run.bulkEdit}}** to change fields on your selected records in {{label:data_analysis_run}}. Review your selection and the values you want to change before continuing. Check the result for records that could not be updated.
|
||||
- key: process:data_analysis_run.bulkEditWithFile;slot:help
|
||||
summary: "Update existing records using values from a file."
|
||||
content: |
|
||||
Use **{{label:data_analysis_run.bulkEditWithFile}}** to update records in {{label:data_analysis_run}}. Match your file columns to fields and identify the existing records to update. Review the input before continuing and check the result for errors.
|
||||
- key: process:data_analysis_run.bulkDelete;slot:help
|
||||
summary: "Delete multiple selected records."
|
||||
content: |
|
||||
Use **{{label:data_analysis_run.bulkDelete}}** to remove selected records from {{label:data_analysis_run}}. Review your selection carefully before confirming deletion. Check the result for records that could not be deleted.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -136,6 +136,13 @@ class DataAnalysisApiTest extends EngineFixture
|
||||
}
|
||||
var response = invoker.invoke(request);
|
||||
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(status);
|
||||
if(status >= 400)
|
||||
{
|
||||
for(String field : List.of("error", "code", "message", "traceId"))
|
||||
{
|
||||
assertThat(response.getJson().path(field).asText()).as(field).isNotBlank();
|
||||
}
|
||||
}
|
||||
return (response.getJson());
|
||||
}
|
||||
|
||||
@@ -282,7 +289,7 @@ class DataAnalysisApiTest extends EngineFixture
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Generated fragment is committed verbatim and has all fourteen operations.
|
||||
** Generated fragment matches the committed contract and validates all references.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
@@ -296,8 +303,9 @@ class DataAnalysisApiTest extends EngineFixture
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, yaml);
|
||||
}
|
||||
assertThat(Files.exists(path)).as("Regenerate with ./mvnw -o test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT").isTrue();
|
||||
assertThat(Files.exists(path)).as("Regenerate with ./mvnw -o test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.99-SNAPSHOT").isTrue();
|
||||
assertThat(Files.readString(path).replace("\r\n", "\n")).as("OpenAPI changed: regenerate with -Dopenapi.write=true").isEqualTo(yaml.replace("\r\n", "\n"));
|
||||
assertThat(UiApiContract.check(yaml, Files.readString(path), null, 1, "-Dopenapi.write=true")).isEmpty();
|
||||
}
|
||||
@Test
|
||||
void pivotHttp() throws Exception
|
||||
@@ -364,7 +372,7 @@ class DataAnalysisApiTest extends EngineFixture
|
||||
}
|
||||
relationshipCountDrills(call(HttpMethod.POST, "/tables/{table}/crosstab", Map.of("rowField", "status", "columnField", "customer"), 200));
|
||||
// All new schemas are discoverable from the same served OpenAPI catalog.
|
||||
assertThat(com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi.extension().getSemanticVersion()).isEqualTo("1.4.0");
|
||||
assertThat(com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi.extension().getSemanticVersion()).isEqualTo("1.5.0");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -25,6 +25,9 @@ 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.qbits.dataanalysis.permissions.DataAnalysisPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
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;
|
||||
@@ -53,7 +56,15 @@ class DataAnalysisHomeTest
|
||||
{
|
||||
assertThat(widget.getType()).isIn("multiStatistics", "table");
|
||||
var data = new DataAnalysisHome().render(new RenderWidgetInput().withWidgetMetaData(widget)).getWidgetData();
|
||||
if(data instanceof TableData table)
|
||||
if(data instanceof TableData table && DataAnalysisHome.SETUP.equals(widget.getName()))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// the Setup card is never empty: it says what is set up and what is next //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(table.getColumns()).hasSizeLessThanOrEqualTo(4);
|
||||
assertThat(table.getRows()).hasSize(5);
|
||||
}
|
||||
else if(data instanceof TableData table)
|
||||
{
|
||||
assertThat(table.getColumns()).hasSizeLessThanOrEqualTo(4);
|
||||
assertThat(table.getRows()).isEmpty();
|
||||
@@ -61,12 +72,43 @@ class DataAnalysisHomeTest
|
||||
}
|
||||
}
|
||||
QContext.init(instance, TestSupport.user("alice"));
|
||||
assertThat(DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData()).allSatisfy(g ->
|
||||
assertThat(DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData()).isEmpty();
|
||||
assertThat(((TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, Instant.now())).getNoRowsFoundHTML()).doesNotContain("Unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupIsOffTheBusinessHomeAndAdminOnly() throws Exception
|
||||
{
|
||||
var instance = TestSupport.instance();
|
||||
assertThat(DataAnalysisHome.homeWidgetNames()).doesNotContain(DataAnalysisHome.SETUP).containsExactly(DataAnalysisHome.HOME_WIDGET,
|
||||
DataAnalysisHome.STARRED, DataAnalysisHome.FIRING, DataAnalysisHome.RECENT);
|
||||
assertThat(instance.getApp("dataAnalysis").getWidgets()).doesNotContain(DataAnalysisHome.SETUP);
|
||||
assertThat(DataAnalysisQBitProducer.SYSTEM_WIDGETS).containsExactly(DataAnalysisHome.SETUP);
|
||||
assertThat(instance.getWidgets()).containsKey(DataAnalysisHome.SETUP);
|
||||
|
||||
QContext.init(instance, TestSupport.admin());
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), DataAnalysisHome.SETUP)).isTrue();
|
||||
assertThat(DataAnalysisHome.setup().getRows()).hasSize(5);
|
||||
assertThat(new DataAnalysisHome().render(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(DataAnalysisHome.SETUP))).getWidgetData()).isInstanceOf(TableData.class);
|
||||
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions(DataAnalysisPermissions.SAVED_READ));
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), DataAnalysisHome.SETUP)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedReadOnlyUserGetsNoLockedShells() throws Exception
|
||||
{
|
||||
var instance = TestSupport.instance();
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions(DataAnalysisPermissions.SAVED_READ));
|
||||
List<String> visible = DataAnalysisHome.homeWidgetNames().stream().filter(name -> PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), name)).toList();
|
||||
assertThat(visible).containsExactly(DataAnalysisHome.HOME_WIDGET, DataAnalysisHome.STARRED);
|
||||
for(String name : visible)
|
||||
{
|
||||
assertThat(g.getStatisticList()).isEmpty();
|
||||
assertThat(g.getSubheader()).contains("Unavailable");
|
||||
});
|
||||
assertThat(((TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, Instant.now())).getNoRowsFoundHTML()).contains("Unavailable");
|
||||
var data = DataAnalysisHome.render(name, Instant.now());
|
||||
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(data)).doesNotContain("Unavailable").doesNotContain("permission required");
|
||||
}
|
||||
var groups = DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData();
|
||||
assertThat(groups).extracting(g -> g.getHeader()).containsExactly("Saved analyses");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfigValidationException;
|
||||
@@ -69,7 +70,7 @@ class DataAnalysisMetadataTest
|
||||
assertThat(instance.getTables()).containsOnlyKeys(DataAnalysisTables.ALL);
|
||||
assertThat(instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("data_analysis_"))).hasSize(16);
|
||||
assertThat(instance.getJoins()).hasSize(3);
|
||||
assertThat(instance.getWidgets()).hasSize(8).containsKey(DataAnalysisHome.HOME_WIDGET);
|
||||
assertThat(instance.getWidgets()).hasSize(9).containsKey(DataAnalysisHome.HOME_WIDGET);
|
||||
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getProcesses()).isNullOrEmpty();
|
||||
assertThat(instance.getApp("dataAnalysis").getLabel()).isEqualTo("Analyses");
|
||||
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getTables()).containsExactlyElementsOf(DataAnalysisTables.NAV_TABLES);
|
||||
@@ -115,11 +116,17 @@ class DataAnalysisMetadataTest
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getNamespace()).isEqualTo("analysis");
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
|
||||
String artifactVersion = System.getProperty("qbit.artifactVersion");
|
||||
assertThat(artifactVersion).isNotBlank();
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(artifactVersion).isEqualTo(DataAnalysisQBitProducer.VERSION);
|
||||
assertThat(producer.getQBitMetaData().getGroupId()).isEqualTo("com.kingsrook.qbits");
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-data-analysis");
|
||||
assertThat(producer.getPackageNameForFindingMetaDataProducers()).endsWith(".metadata");
|
||||
producer.produce(instance);
|
||||
assertThat(HelpBundles.of(instance).getBundles().getFirst().getVersion())
|
||||
.isEqualTo(artifactVersion).isEqualTo(producer.getQBitMetaData().getVersion());
|
||||
assertThat(HelpBundles.of(instance).getBundles().getFirst().getEntries()).anyMatch(entry -> entry.getKey().equals("app:customAnalysis;slot:help"))
|
||||
.noneMatch(entry -> entry.getKey().equals("app:dataAnalysis;slot:help"));
|
||||
assertThat(instance.getApp("customAnalysis").getParentAppName()).isEqualTo("admin");
|
||||
assertThat(instance.getApp("admin").getChildren()).contains(instance.getApp("customAnalysis"));
|
||||
assertThat(new DataAnalysisQBitProducer().isEnabled()).isFalse();
|
||||
@@ -206,7 +213,7 @@ class DataAnalysisMetadataTest
|
||||
"modify_date TIMESTAMP WITHOUT TIME ZONE", "rows_scanned BIGINT", "result_json TEXT", "shared BOOLEAN", "day INTEGER",
|
||||
"owner_user_id VARCHAR(250) NOT NULL", "date_field VARCHAR(250) NOT NULL", "last_value NUMERIC", "last_message TEXT", "CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_table_config_1 ON data_analysis_table_config (table_name)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_saved_star_1 ON data_analysis_saved_star (saved_id, user_id)",
|
||||
"CHECK (shared = TRUE OR read_owner_user_id IS NOT NULL)");
|
||||
"CHECK (shared IS TRUE OR read_owner_user_id IS NOT NULL)");
|
||||
assertThat(ddl).endsWith(";\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute generated saved-analysis DDL on H2 in PostgreSQL mode, including SQL's three-valued
|
||||
** CHECK semantics. These direct writes intentionally bypass SavedCustomizer.
|
||||
*******************************************************************************/
|
||||
class DataAnalysisSchemaTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A null read owner grants shared reads, so only an explicit true shared flag
|
||||
** may omit it. Null and omitted flags must fail closed like false.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void savedAnalysisRequiresReadOwnerUnlessExplicitlyShared() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:saved_schema_" + UUID.randomUUID() + ";MODE=PostgreSQL;DATABASE_TO_UPPER=FALSE");
|
||||
var statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : DataAnalysisSchema.postgresScript(new DataAnalysisQBitConfig().withBackendName("database")).split(";"))
|
||||
{
|
||||
if(sql.stripLeading().startsWith("CREATE TABLE IF NOT EXISTS data_analysis_saved ("))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> statement.executeUpdate("INSERT INTO data_analysis_saved (name, table_name, kind, definition_json, owner_user_id) VALUES ('omitted flag', 'test_table', 'PIVOT', '{}', 'alice')"))
|
||||
.isInstanceOf(SQLException.class).hasMessageContaining("ck_data_analysis_saved_read_owner");
|
||||
assertThatThrownBy(() -> insert(connection, null, null)).isInstanceOf(SQLException.class).hasMessageContaining("ck_data_analysis_saved_read_owner");
|
||||
assertThatThrownBy(() -> insert(connection, false, null)).isInstanceOf(SQLException.class).hasMessageContaining("ck_data_analysis_saved_read_owner");
|
||||
|
||||
insert(connection, true, null);
|
||||
insert(connection, false, "alice");
|
||||
insert(connection, null, "alice");
|
||||
try(var rows = statement.executeQuery("SELECT COUNT(*) FROM data_analysis_saved"))
|
||||
{
|
||||
assertThat(rows.next()).isTrue();
|
||||
assertThat(rows.getInt(1)).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nullable values are bound explicitly so no Java boolean conversion can hide
|
||||
** the SQL null case that caused the original constraint to admit private rows.
|
||||
*******************************************************************************/
|
||||
private static void insert(Connection connection, Boolean shared, String readOwner) throws SQLException
|
||||
{
|
||||
try(var statement = connection.prepareStatement("INSERT INTO data_analysis_saved (name, table_name, kind, definition_json, owner_user_id, shared, read_owner_user_id) VALUES ('schema test', 'test_table', 'PIVOT', '{}', 'alice', ?, ?)"))
|
||||
{
|
||||
statement.setObject(1, shared);
|
||||
statement.setString(2, readOwner);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every data_analysis_* table:
|
||||
** - runtime code (not just its model, metadata or home screen) reads or writes
|
||||
** it, so no table exists only on paper;
|
||||
** - it is either seeded (analysis settings, by the Create rows action, from the
|
||||
** configuration at run time) or listed here with why it starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get created.
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class DataAnalysisTablesGuardrailTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "dataanalysis");
|
||||
|
||||
/*******************************************************************************
|
||||
** Seeded from the code configuration by "Create rows for analyzable tables".
|
||||
*******************************************************************************/
|
||||
static final List<String> SEEDED = List.of(DataAnalysisTables.TABLE_CONFIG);
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.of(
|
||||
DataAnalysisTables.SAVED, "user content: a person saves an analysis",
|
||||
DataAnalysisTables.SAVED_STAR, "user content: a person stars a saved analysis",
|
||||
DataAnalysisTables.SCHEDULE, "user content: a person schedules a saved analysis",
|
||||
DataAnalysisTables.WATCH, "user content: a person adds a watch to a saved analysis",
|
||||
DataAnalysisTables.RUN, "runtime: someone starts an exact analysis (it is also the result cache)");
|
||||
|
||||
private QInstance instance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
instance = TestSupport.instance();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> constantByTable = constantByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app")) && !p.startsWith(MAIN.resolve("help"))
|
||||
&& !p.getFileName().toString().equals("DataAnalysisSchema.java"))
|
||||
.map(DataAnalysisTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : analysisTables())
|
||||
{
|
||||
String constant = constantByTable.get(table);
|
||||
assertThat(constant).as("constant for " + table).isNotNull();
|
||||
Pattern use = Pattern.compile("DataAnalysisTables\\." + constant + "\\b");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (" + constant + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
for(String table : analysisTables())
|
||||
{
|
||||
boolean seeded = SEEDED.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// the seeded table has a real action that writes its rows, named in its help //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(instance.getProcess(TableConfigDefaultsProcess.NAME).getTableName()).isEqualTo(DataAnalysisTables.TABLE_CONFIG);
|
||||
assertThat(instance.getProcess(TableConfigDefaultsProcess.NAME).getMinInputRecords()).isEqualTo(0);
|
||||
assertThat(help(DataAnalysisTables.TABLE_CONFIG).getContent()).contains(instance.getProcess(TableConfigDefaultsProcess.NAME).getLabel());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
HelpBundleAssertions.forInstance(instance).bundles("help/qbit-data-analysis.yaml")
|
||||
.owning(target -> target.name().startsWith("data_analysis_") || target.name().startsWith("dataAnalysis") || target.name().equals("DATA_ANALYSIS_SAVED"))
|
||||
.assertComplete();
|
||||
for(String table : analysisTables())
|
||||
{
|
||||
assertThat(help(table).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
// RC.7's core gate requires apps, tables, processes and fields; also cover our widgets and value sources.
|
||||
List<String> keys = HelpBundles.of(instance).getBundles().getFirst().getEntries().stream().map(HelpBundleEntry::getKey).toList();
|
||||
HelpTargetCatalog.collect(instance).stream()
|
||||
.filter(target -> List.of("widget", "possibleValueSource").contains(target.kind()))
|
||||
.filter(target -> target.name().startsWith("data_analysis_") || target.name().startsWith("dataAnalysis") || target.name().equals("DATA_ANALYSIS_SAVED"))
|
||||
.forEach(target -> assertThat(keys).contains(target.key()));
|
||||
assertThat(keys).doesNotHaveDuplicates();
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
assertThat(HelpBundles.of(instance).getBundles()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host bundles override the qbit without replacing its resource.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostBundleOverridesQbit()
|
||||
{
|
||||
HelpBundles.register(instance, "help/host-override.yaml");
|
||||
var winner = HelpBundles.of(instance).resolveWinningEntries()
|
||||
.get(new HelpBundles.EntryKey("table:data_analysis_saved;slot:help", "ALL_SCREENS"));
|
||||
assertThat(winner.bundle().getId()).isEqualTo("test-host");
|
||||
assertThat(winner.entry().getSummary()).isEqualTo("Your host's saved analysis guidance.");
|
||||
assertThat(help(DataAnalysisTables.SAVED).getContent()).contains("How rows get created");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the registered bundle without requiring host help storage.
|
||||
*******************************************************************************/
|
||||
private HelpBundleEntry help(String table)
|
||||
{
|
||||
return (HelpBundles.of(instance).getBundles().getFirst().getEntries().stream()
|
||||
.filter(entry -> entry.getKey().equals("table:" + table + ";slot:help")).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> analysisTables()
|
||||
{
|
||||
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("data_analysis_")).sorted().toList();
|
||||
assertThat(tables).containsExactlyInAnyOrderElementsOf(DataAnalysisTables.ALL);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DataAnalysisTables declares X = "data_analysis_x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> constantByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Matcher m = Pattern.compile("String\\s+([A-Z_]+)\\s*=\\s*\"(data_analysis_[a-z_]+)\"").matcher(read(MAIN.resolve("model").resolve("DataAnalysisTables.java")));
|
||||
while(m.find())
|
||||
{
|
||||
rs.put(m.group(2), m.group(1));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -107,8 +107,11 @@ class FieldAnalysisAccessTest extends EngineFixture
|
||||
var fallback = new FieldSecurityLockFieldAccessResolver();
|
||||
assertThat(fallback.resolve(table, table.getField("amount"))).isEqualTo(FieldAnalysisAccess.FULL);
|
||||
assertThat(fallback.resolve(table, table.getField("secret"))).isEqualTo(FieldAnalysisAccess.NONE);
|
||||
// The snapshot has no tier package. Test the actual default selection.
|
||||
assertThat(CoreTierFieldAccessResolver.defaultResolver()).isInstanceOf(FieldSecurityLockFieldAccessResolver.class);
|
||||
// RC.7 supplies the core tier API; verify discovery and effective access.
|
||||
var defaultResolver = CoreTierFieldAccessResolver.defaultResolver();
|
||||
assertThat(defaultResolver).isInstanceOf(CoreTierFieldAccessResolver.class);
|
||||
assertThat(defaultResolver.resolve(table, table.getField("amount"))).isEqualTo(FieldAnalysisAccess.FULL);
|
||||
assertThat(defaultResolver.resolve(table, table.getField("secret"))).isEqualTo(FieldAnalysisAccess.NONE);
|
||||
var resolver = new CoreTierFieldAccessResolver(TierApi.class);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -118,6 +118,28 @@ abstract class PivotContractTest extends EngineFixture
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A relative date in the filter ("created in the last N days") is evaluated
|
||||
** by core when the pivot runs, also after the JSON round trip of an HTTP
|
||||
** request or a saved definition, and the drills carry it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void pivotWithRelativeDateFilter() throws Exception
|
||||
{
|
||||
PivotRequest everything = pivot(List.of("status"), List.of()).withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()
|
||||
.withCriteria("created", QCriteriaOperator.GREATER_THAN_OR_EQUALS, com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset.minus(36500, java.time.temporal.ChronoUnit.DAYS)));
|
||||
for(PivotRequest request : List.of(everything, JsonUtils.toObject(JsonUtils.toJson(everything), PivotRequest.class)))
|
||||
{
|
||||
PivotResult result = service.pivot(request);
|
||||
equalNumber(result.getGrandTotals().getFirst(), "12");
|
||||
countDrills(result);
|
||||
}
|
||||
|
||||
PivotRequest lastDay = pivot(List.of("status"), List.of()).withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()
|
||||
.withCriteria("created", QCriteriaOperator.GREATER_THAN_OR_EQUALS, com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset.minus(1, java.time.temporal.ChronoUnit.DAYS)));
|
||||
assertThat(service.pivot(JsonUtils.toObject(JsonUtils.toJson(lastDay), PivotRequest.class)).getCells()).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify or support the pivot and field-access contract.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
|
||||
import com.kingsrook.qbits.dataanalysis.processes.ScheduleActions;
|
||||
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
|
||||
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
|
||||
@@ -47,6 +48,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProvide
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
|
||||
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -160,6 +163,27 @@ class SchedulesTest extends EngineFixture
|
||||
assertThat(WatchesTest.Sink.raised).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** With the host's data-analysis principal declared, schedule inventory runs
|
||||
** as the principal (its grants decide - a principal without them finds
|
||||
** nothing due), never as a self-made session.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inventoryRunsAsTheDeclaredPrincipal() throws Exception
|
||||
{
|
||||
schedule("PIVOT", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT"))), "CSV");
|
||||
config.withServicePrincipal("analysis-bare");
|
||||
ServiceIdentitiesMetaData.declare(instance, new QServicePrincipalMetaData().withName("analysis-bare"));
|
||||
assertThatThrownBy(() -> runner.runDue(NOW)).isInstanceOf(QException.class);
|
||||
assertThat(Mail.sent).isEmpty();
|
||||
|
||||
ServiceIdentitiesMetaData.declare(instance, new QServicePrincipalMetaData().withName("data-analysis")
|
||||
.withLocalPermissions("data_analysis.saved.read", "data_analysis.saved.edit").withLocalSecurityKeyValue(DataAnalysisPermissions.ADMIN, true));
|
||||
config.withServicePrincipal("data-analysis");
|
||||
assertThat(runner.runDue(NOW)).isEqualTo(1);
|
||||
assertThat(Mail.sent).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerFailureAndMissingOwnerRaiseVisibleAlerts() throws Exception
|
||||
{
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
|
||||
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisSetupStatus;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Create rows for analyzable tables" writes the settings that already apply
|
||||
** and changes nothing; the rows then drive the runtime (including the result
|
||||
** cache minutes); and the Setup card reports it all. Runs on the memory backend
|
||||
** with the instance's own runtime, exactly as a host would.
|
||||
*******************************************************************************/
|
||||
class TableConfigDefaultsTest extends EngineFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The settings every analyzable table resolves to, as one comparable string.
|
||||
*******************************************************************************/
|
||||
private String effective(String table) throws Exception
|
||||
{
|
||||
EffectiveConfig c = DataAnalysisRuntime.service().getConfigResolver().resolve(table);
|
||||
return (c.getEnabled() + " " + c.getMaxRows() + " " + c.getSampleRows() + " " + c.getSampleThresholdRows() + " " + c.getMinGroupSize() + " "
|
||||
+ c.getCacheTtlMinutes() + " " + new java.util.TreeSet<>(c.getAnalyzableFields()) + " " + new java.util.TreeSet<>(c.getExcludedFields()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLine> create() throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(TableConfigDefaultsProcess.NAME);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> configRows() throws Exception
|
||||
{
|
||||
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG).withFilter(new QQueryFilter());
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows hold the effective settings (table defaults over global defaults),
|
||||
** excluded tables get none, and every table resolves exactly as before.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rowsRestateTheEffectiveSettingsAndChangeNothing() throws Exception
|
||||
{
|
||||
DataAnalysisQBitConfig runtime = DataAnalysisRuntime.service().getConfigResolver().getDefaults();
|
||||
runtime.withDefaultMaxRows(500).withTableDefaults(Map.of("orders", new TableAnalysisDefaults().withMaxRows(100).withSampleRows(8).withMinGroupSize(2)
|
||||
.withAnalyzableFields(Set.of("amount", "note")).withExcludedFields(Set.of("note"))));
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
String ordersBefore = effective("orders");
|
||||
String customerBefore = effective("customer");
|
||||
assertThat(ordersBefore).startsWith("true 100 8 ");
|
||||
|
||||
List<ProcessSummaryLine> summary = create();
|
||||
assertThat(summary).isNotEmpty();
|
||||
List<QRecord> rows = configRows();
|
||||
assertThat(rows).extracting(row -> row.getValueString("table_name")).containsExactlyInAnyOrder("customer", "orders");
|
||||
QRecord orders = rows.stream().filter(row -> "orders".equals(row.getValueString("table_name"))).findFirst().orElseThrow();
|
||||
assertThat(orders.getValueInteger("max_rows")).isEqualTo(100);
|
||||
assertThat(orders.getValueInteger("sample_rows")).isEqualTo(8);
|
||||
assertThat(orders.getValueInteger("min_group_size")).isEqualTo(2);
|
||||
assertThat(orders.getValueInteger("cache_ttl_minutes")).isEqualTo(60);
|
||||
assertThat(orders.getValueString("analyzable_fields")).isEqualTo("amount,note");
|
||||
assertThat(orders.getValueString("excluded_fields")).isEqualTo("note");
|
||||
QRecord customer = rows.stream().filter(row -> "customer".equals(row.getValueString("table_name"))).findFirst().orElseThrow();
|
||||
assertThat(customer.getValueInteger("max_rows")).isEqualTo(500);
|
||||
assertThat(customer.getValueString("analyzable_fields")).isNull();
|
||||
assertThat(customer.getValueBoolean("enabled")).isTrue();
|
||||
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
assertThat(effective("orders")).isEqualTo(ordersBefore);
|
||||
assertThat(effective("customer")).isEqualTo(customerBefore);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables excluded by configuration get no row; a table added later gets one on
|
||||
** the next run; existing rows are never touched and an edit drives the runtime.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void insertOnlyExcludedTablesSkippedEditsLive() throws Exception
|
||||
{
|
||||
DataAnalysisRuntime.service().getConfigResolver().getDefaults().withExcludeTables(Set.of("customer"));
|
||||
create();
|
||||
assertThat(configRows()).extracting(row -> row.getValueString("table_name")).containsExactly("orders");
|
||||
|
||||
QRecord orders = configRows().getFirst();
|
||||
UpdateInput update = new UpdateInput(DataAnalysisTables.TABLE_CONFIG).withRecords(List.of(new QRecord().withValue("id", orders.getValue("id")).withValue("max_rows", 7)));
|
||||
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
new UpdateAction().execute(update);
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
assertThat(effective("orders")).startsWith("true 7 ");
|
||||
|
||||
instance.addTable(new QTableMetaData().withName("extra").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER)));
|
||||
create();
|
||||
assertThat(configRows()).extracting(row -> row.getValueString("table_name")).containsExactlyInAnyOrder("orders", "extra");
|
||||
assertThat(configRows().stream().filter(row -> "orders".equals(row.getValueString("table_name"))).findFirst().orElseThrow().getValueInteger("max_rows")).isEqualTo(7);
|
||||
|
||||
assertThat(create()).singleElement().satisfies(line -> assertThat(line.getMessage()).contains("already has a settings row"));
|
||||
assertThat(configRows()).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The result cache uses the table's settings row, not only the global default:
|
||||
** a row with more minutes keeps an aged run as a cache hit (the global 60
|
||||
** minutes would miss it), a row with 0 turns caching off, and a row with 1
|
||||
** minute misses a run that is two minutes old.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingsRowCacheMinutesChangeCaching() throws Exception
|
||||
{
|
||||
String id = service.startExactRun(column());
|
||||
age(id, 7200);
|
||||
assertThat(service.startExactRun(column())).as("default 60 minutes: a two hour old run is not reused").isNotEqualTo(id);
|
||||
|
||||
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders").withValue("cache_ttl_minutes", 240));
|
||||
service.getConfigResolver().invalidate();
|
||||
String fresh = service.startExactRun(column());
|
||||
age(fresh, 7200);
|
||||
assertThat(service.startExactRun(column())).as("row with 240 minutes: the two hour old run is reused").isEqualTo(fresh);
|
||||
|
||||
setTtl(1);
|
||||
age(fresh, 120);
|
||||
assertThat(service.startExactRun(column())).as("row with 1 minute: a two minute old run is not reused").isNotEqualTo(fresh);
|
||||
|
||||
setTtl(0);
|
||||
String zero = service.startExactRun(column());
|
||||
assertThat(service.startExactRun(column())).as("row with 0 minutes: never reused").isNotEqualTo(zero);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private ColumnProfileRequest column()
|
||||
{
|
||||
return (new ColumnProfileRequest().withTableName("orders").withFieldName("amount"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make a stored run look older.
|
||||
*******************************************************************************/
|
||||
private void age(String id, int seconds) throws Exception
|
||||
{
|
||||
UpdateInput update = new UpdateInput(DataAnalysisTables.RUN)
|
||||
.withRecords(List.of(new QRecord().withValue("id", Integer.valueOf(id)).withValue("finished_at", Instant.now().minusSeconds(seconds))));
|
||||
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
new UpdateAction().execute(update);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Edit the orders settings row's cache minutes.
|
||||
*******************************************************************************/
|
||||
private void setTtl(int minutes) throws Exception
|
||||
{
|
||||
QRecord row = configRows().stream().filter(r -> "orders".equals(r.getValueString("table_name"))).findFirst().orElseThrow();
|
||||
UpdateInput update = new UpdateInput(DataAnalysisTables.TABLE_CONFIG).withRecords(List.of(new QRecord().withValue("id", row.getValue("id")).withValue("cache_ttl_minutes", minutes)));
|
||||
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
new UpdateAction().execute(update);
|
||||
service.getConfigResolver().invalidate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Result cache line says what each table resolves to, including mixed
|
||||
** settings, and a settings error is shown as needing attention, not as "on".
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resultCacheLineIsAccurate() throws Exception
|
||||
{
|
||||
DataAnalysisRuntime.service().getConfigResolver().getDefaults().withDefaultCacheTtlMinutes(0);
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
assertThat(DataAnalysisSetupStatus.load().get(1).detail()).startsWith("Off");
|
||||
|
||||
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders").withValue("cache_ttl_minutes", 30));
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
DataAnalysisSetupStatus.Item mixed = DataAnalysisSetupStatus.load().get(1);
|
||||
assertThat(mixed.state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
|
||||
assertThat(mixed.detail()).contains("default 0 min").contains("1 table: off").contains("1 table: 30 min");
|
||||
|
||||
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "customer").withValue("cache_ttl_minutes", -5));
|
||||
DataAnalysisRuntime.service().getConfigResolver().invalidate();
|
||||
DataAnalysisSetupStatus.Item broken = DataAnalysisSetupStatus.load().get(1);
|
||||
assertThat(broken.state()).isEqualTo(DataAnalysisSetupStatus.State.UNKNOWN);
|
||||
assertThat(broken.detail()).contains("could not be read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: defaults before rows, set up after, from the runtime's own
|
||||
** resolution; optional features say what is missing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void setupCard() throws Exception
|
||||
{
|
||||
List<DataAnalysisSetupStatus.Item> before = DataAnalysisSetupStatus.load();
|
||||
assertThat(before).extracting(DataAnalysisSetupStatus.Item::label).containsExactly("Analysis settings", "Result cache", "Saved analyses", "Watches", "Schedules");
|
||||
assertThat(before.get(0).state()).isEqualTo(DataAnalysisSetupStatus.State.DEFAULT);
|
||||
assertThat(before.get(0).detail()).contains("All 2 analyzable tables are on");
|
||||
assertThat(before.get(0).nextStep()).contains("Create rows for analyzable tables");
|
||||
assertThat(before.get(1).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
|
||||
assertThat(before.get(1).detail()).contains("60 minutes");
|
||||
assertThat(before.get(3).state()).isEqualTo(DataAnalysisSetupStatus.State.OPTIONAL);
|
||||
assertThat(before.get(3).detail()).contains("No scheduler is configured");
|
||||
|
||||
create();
|
||||
List<DataAnalysisSetupStatus.Item> after = DataAnalysisSetupStatus.load();
|
||||
assertThat(after.get(0).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
|
||||
assertThat(after.get(0).detail()).contains("2 settings rows for 2 analyzable tables");
|
||||
|
||||
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("admin", false));
|
||||
assertThat(DataAnalysisSetupStatus.load().get(2).state()).isEqualTo(DataAnalysisSetupStatus.State.OK);
|
||||
|
||||
TableData card = DataAnalysisHome.setup();
|
||||
assertThat(card.getRows()).hasSize(5);
|
||||
assertThat(card.getRows().getFirst().get("c1")).isEqualTo("Set up");
|
||||
assertThat(String.valueOf(card.getRows().getFirst().get("c0"))).contains("<a href=");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
bundle: test-host
|
||||
version: 1
|
||||
layer: HOST
|
||||
entries:
|
||||
- key: table:data_analysis_saved;slot:help
|
||||
summary: "Your host's saved analysis guidance."
|
||||
content: "Use your host's saved analysis workflow."
|
||||
Reference in new issue
Block a user