Compare commits

...
79 Commits
Author SHA1 Message Date
Gitea Actions afacfe1e67 Bump version to 0.1.0-RC.7 [skip ci] 2026-11-04 00:23:34 +00:00
Gitea Actions cdc3d953e4 Merge pull request 'Merge develop into release/0.1 for the next 0.1.0 RC' (#40) from develop into release/0.1 2026-11-03 22:07:55 +00:00
james.maes f2fd8b1b0f Merge pull request 'ci: reconcile legacy release ancestry for normal RC cuts' (#39) from fix/reconcile-release-ci into develop
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
qrun-release / postpublish (push) Successful in 2m5s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 13s
Shared Java Maven CI with Docker / secrets (push) Successful in 13s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 3s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 4m40s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / node-10 (push) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 4m54s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
Shared Java Maven CI with Docker / gate (push) Successful in 10s
qrun-release / release (push) Successful in 5m5s
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
Shared QRun source version / version (push) Successful in 2m5s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 4m26s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
2026-11-03 21:42:29 +00:00
35731a101d ci: reconcile legacy release ancestry with canonical develop
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 13s
Shared Java Maven CI with Docker / verify (push) Successful in 5m8s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 4m33s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 4m50s
Shared Java Maven CI with Docker / gate (push) Successful in 12s
qrun-release / release (push) Successful in 5m24s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 14s
Preserve current develop CI configuration and SNAPSHOT revision; application source is unchanged. Reconcile the prior release ancestry so normal Gitea RC cuts can merge without obsolete CI conflicts.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 15:36:08 -06:00
james.maes 1836afce45 Merge pull request 'docs: record Forge prerequisite fix for release' (#38) from docs/forge-release-notes into develop
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
Shared Java Maven CI with Docker / secrets (push) Successful in 15s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 15s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 4m31s
Shared Java Maven CI with Docker / verify (push) Successful in 4m40s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
qrun-release / postpublish (push) Successful in 1m6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 4m19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 8s
Shared Java Maven CI with Docker / gate (push) Successful in 9s
qrun-release / release (push) Successful in 4m50s
Shared QRun source version / version (push) Successful in 1m5s
2026-11-03 21:24:29 +00:00
ccc93ea803 docs: record Forge prerequisite fix for release
Shared Java Maven CI with Docker / verify (push) Successful in 4m33s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 13s
Shared Java Maven CI with Docker / gate (push) Successful in 11s
qrun-release / release (push) Successful in 4m45s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 4m16s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 5m3s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Claude <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 15:15:54 -06:00
james.maes e7926cbb20 Merge pull request 'fix: reject private analyses without a read owner' (#37) from james.maes/fix-private-analysis-read-owner into develop
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 4m34s
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
qrun-release / postpublish (push) Successful in 3m14s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
Shared Java Maven CI with Docker / secrets (push) Successful in 19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 21s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 6m6s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
Shared Java Maven CI with Docker / gate (push) Successful in 12s
qrun-release / release (push) Successful in 4m51s
QRun-IO/ci-policy: ci / node-9 (push) Skipped
Shared QRun source version / version (push) Successful in 3m14s
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 5m26s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 12s
2026-11-03 21:01:41 +00:00
James MaesandCodex 2dcc073f51 fix: reject private analyses without a read owner
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 4m22s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 39m52s
Use an explicit true sharing check so nullable flags cannot pass SQL CHECK as UNKNOWN. Add a generated-schema SQL regression and document the forward host migration.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-03 12:27:26 -06:00
forge-recovery 36f726417c ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
qrun-release / postpublish (push) Failing after 10m34s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 25m54s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 15s
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 6m17s
Shared Java Maven CI with Docker / gate (push) Successful in 18s
Shared QRun source version / version (push) Failing after 10m34s
qrun-release / release (push) Successful in 35m51s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 21s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 5m28s
2026-11-02 22:15:31 +00:00
forge-recovery f35f8130b7 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 26s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 13m58s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 9s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 5m50s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 19s
2026-11-02 21:31:38 +00:00
forge-recoveryandCodex 665c14bd6d ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 4m50s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 1h0m49s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:15:44 +00:00
d218ad0f4f ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 12s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h38m43s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 3m48s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 2h49m11s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:48:40 +00:00
a398dbf55a ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Shared Java Maven CI with Docker / secrets (push) Successful in 12s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 1h8m43s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h8m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:17:39 +00:00
forge-recoveryandCodex 59322faaad ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 4m30s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Successful in 49m5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 16s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:10:12 +00:00
james.maesandCodex 368d2c0e3a ci: adopt managed orb 0.8 patch track (#31)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 16:04:08 -05:00
james.maesandCodex 68067df16b ci: adopt shared orb 0.7.18 (#30)
* ci: adopt shared orb 0.7.17

Co-Authored-By: Codex <noreply@openai.com>

* ci: adopt corrected orb bootstrap 0.7.18

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:40:55 -05:00
james.maesandCodex 8cc13ff7e7 ci: centralize release branch verification and workflows (#29)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 12:39:01 -05:00
james.maesandCodex 6a520e5452 ci: use the shared branch-aware CodeQL worker (#28)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:46:49 -05:00
james.maesandCodex b1ede9f887 ci: adopt central orb 0.7.12 and stable gates (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:56:56 -05:00
james.maesandCodex 81552784e9 ci: centralize rc release completion in qqq orb (#25)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:26:29 -05:00
james.maesandCodex a68d557fb2 ci: adopt shared orb 0.7.11 (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:01:55 -05:00
james.maes 36527b06ff Merge pull request #23 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 18:57:32 -05:00
james.maesandCodex 7c78003765 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:52:18 -05:00
CircleCI 2b4b2c48e5 Bump version to 0.1.0-RC.6 [skip ci] 2026-10-16 18:46:07 +00:00
james.maesandClaude Opus 5.5 e82960ac52 Merge develop into release/0.1 for 0.1.0-RC.6 (#19 schedules and watches run as a service principal, QQQ RC.19)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:39:16 -05:00
james.maes ce127037e4 Merge pull request #19 from QRun-IO/feature/service-principal
fix(schedules): schedules and watches run as the data-analysis service principal
2026-10-16 13:33:34 -05:00
james.maesandClaude Opus 5.5 20545be7fc build: QQQ 4.1.0-RC.19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:19:12 -05:00
CircleCI 16b639ce40 Bump version to 0.1.0-RC.5 [skip ci] 2026-10-16 16:01:42 +00:00
github-actions[bot] 2339aea28c Merge develop into release/0.1 for the next 0.1.0 RC 2026-10-16 15:58:10 +00:00
james.maes 96d2e48e47 Merge pull request #20 from QRun-IO/feature/data-analysis-business-home
feat(home): move Setup to admin and omit unpermitted home content
2026-10-16 10:19:27 -05:00
james.maesandClaude Sonnet 5.5 1d2c2b0029 feat(home): move Setup to admin and omit unpermitted home content
The Data Analysis home showed the engine Setup card (max rows, sampling,
cache, scheduler state) to every SAVED_READ user, and rendered
"Unavailable: READ permission required." shells for unreadable groups.

- dataAnalysisSetup is off the home widget list; it stays registered, gated
  to data_analysis.admin with DenyBehavior.HIDDEN. Hosts place
  DataAnalysisQBitProducer.SYSTEM_WIDGETS on their Admin area.
- Each home data widget requires READ on its table and is omitted from
  metadata otherwise.
- Overview groups the user cannot read are left out of the payload; no
  "Unavailable" text remains on the home.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:27:23 -05:00
james.maes 9d1d712040 Merge pull request #18 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:37 -05:00
CircleCI 80e86fd207 Bump version to 0.1.0-RC.4 [skip ci] 2026-10-15 21:25:09 +00:00
james.maesandClaude Opus 5.5 2c1fa83cfd ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:06:05 -05:00
CircleCI 0487f3ac40 Bump version to 0.1.0-RC.3 [skip ci] 2026-10-15 02:26:42 +00:00
james.maesandClaude Opus 5.5 546196af6d Merge develop into release/0.1 for 0.1.0-RC.3 (#13 help bundle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:20:46 -05:00
james.maes 0e0cc0b83d Merge pull request #13 from QRun-IO/feature/help-bundle
feat(help): qbit-data-analysis help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
james.maesandClaude Opus 5.5 73de290a93 feat(help): ship the qbit-data-analysis help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:49:18 -05:00
james.maes b3c8a0412a Merge remote-tracking branch 'origin/feature/watches' into feature/saved 2026-10-13 22:26:08 -05:00
james.maes 191e96b094 Merge remote-tracking branch 'origin/feature/compare' into feature/watches 2026-10-13 22:23:43 -05:00
james.maes 642401e633 Merge remote-tracking branch 'origin/feature/pivot' into feature/compare 2026-10-13 22:19:46 -05:00
james.maes 35901fe71a Merge remote-tracking branch 'origin/feature/profile-endpoints' into feature/pivot 2026-10-13 22:17:27 -05:00
james.maesandClaude Opus 5.5 bb8b431b7f chore(api): regenerate the OpenAPI fragment against QQQ 4.1.0-RC.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:17:04 -05:00
james.maesandClaude Opus 5.5 1641b32296 fix(stats): pool sparse crosstab categories instead of refusing the test
Chi-square now pools the smallest category into Other one at a time until
every expected count is at least five (rows too, for independence), so a
crosstab with a single-record row still gets a valid test.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:23:23 -05:00
james.maesandClaude Opus 5.5 43c7571864 feat(watches): anomaly watches through the alerts SPI, contingent pickers
Watches evaluate a metric per time bucket as their owner (SPIKE, DROP,
OUT_OF_RANGE, NEW_CATEGORY, NULL_RATE_CREEP with robust baselines) on a
hidden scheduled process, raise and resolve data.analysis.anomaly alerts
through QAlerts, and offer Evaluate now / Enable / Disable record actions
and a preview endpoint (extension 1.3.0). Pivot and crosstab labels are
human (Yes/No, Average Annual Salary). Contingent fields: a watch's saved
analysis depends on its table, table names use the core tables PVS, and
analyzable/excluded fields pick from the chosen table's fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:34:19 -05:00
james.maesandClaude Opus 5.5 20b8223ce8 feat(compare): compare, distribution shift, correlation, scatter and crosstabs
Extension 1.2.0: compare two filters field by field with deltas and
significance (two-proportion z, Welch's t, chi-square with validity
rules and plain sentences), distribution shift over time with PSI, a
pushed-down Pearson correlation matrix with sampled scatter and trend,
and crosstabs with chi-square and Cramer's V. Pure-Java statistics
checked against scipy reference values; the privacy guard, field access
and drills apply as for profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:37:03 -05:00
james.maesandClaude Opus 5.5 d591d3d7e8 feat(pivot): pivot / group-by explorer and the field-access resolver seam
POST /tables/{table}/pivot (extension 1.1.0): up to 3 row and 2 column
dimensions with date buckets or nice numeric bins, fields from to-one
exposed joins, count/sum/avg/min/max/distinct/percentile measures,
totals, Other-merging by top-N, the privacy guard and a drill per cell.
FieldAnalysisAccessResolver maps fields to FULL / EXACT_MATCH_ONLY /
NONE (core secured-data tiers when present, FieldSecurityLock otherwise);
only FULL fields are analyzable, drills never name others, and exact-run
cache keys include the user's field-access fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:56:37 -05:00
james.maesandClaude Opus 5.5 d8b52113ca feat(results): human labels, formatted values, nice bins, date labels, current home
Every metric carries a plain-English label and a display-formatted value
(grouping, percent, the field's display format), histogram bins use nice
edges with formatted labels, date buckets are labelled by unit, warnings
appear only when true, and the Analyses home shows saved analyses,
watches and recent runs with honest empty states (and no mojibake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:26:45 -05:00
james.maesandClaude Opus 5.5 c90d6c9866 chore: drop the agent handoff note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:44:00 -05:00
james.maesandClaude Opus 5.5 a91f6c5e03 feat(api): /qqq/v1 data-analysis extension, core pushdown backend, analyze permission
UI API extension data-analysis 1.0.0 (capabilities, column-profile,
distinct-values, table-profile, duplicates, runs) with typed
DataAnalysis*V1 schemas and the committed OpenAPI fragment. The analyze
permission is registered through core TableFeaturePermissionsMetaData.
CoreAnalysisBackend pushes percentiles, stddev, null/blank counts, numeric
and date buckets down through the RC.6 aggregate extensions; estimates
sample natively and scale counts, and the stand-in uses one primary-key
bound instead of an IN list. Builds against QQQ 4.1.0-RC.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:43:50 -05:00
james.maes 1b9f502e23 Merge pull request #2 from QRun-IO/feature/profiles
feat(engine): column and table profiles, drill-through, privacy guard and exact runs
2026-10-13 17:12:32 -05:00
james.maesandClaude Opus 5.5 261d0213ad feat(engine): column and table profiles, drill-through, privacy guard and exact runs
The phase (b) analysis engine: typed results (metrics with drill
filters, charts as data), column and table profiles, distinct values and
duplicates, AnalysisSecurity (analyze permission, table READ, personalized
table and field security, analyzable/excluded fields), the effective
per-table config, the minimum group size guard, data versions, async exact
runs cached in data_analysis_run, and DataAnalysisService as the facade.
The AnalysisBackend port has an RC.5 stand-in that pushes down what core
supports today and streams the rest with row limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:26:15 -05:00
james.maes 4cb5ad910e Merge pull request #1 from QRun-IO/feature/repo-standard
chore: QRun qbit repo standard, producer, tables, permissions and app home
2026-10-13 15:39:30 -05:00
james.maesandClaude Opus 5.5 1076daacde chore: bring the repo to the QRun qbit standard, add producer, tables and home
Repository standard (LICENSE, NOTICE, community pointers, CHANGELOG,
CLAUDE/AGENTS, dependabot, CircleCI with the configure-package-install
pre-step, the release-rc caller workflow, Maven wrapper, checkstyle,
JaCoCo gates, QQQ 4.1.0-RC.5). DataAnalysisQBitProducer and config, the
six data_analysis_ tables with standard permission rules and user record
locks, the analyze permission stand-in, the Analyses app home with real
counts, and the Postgres schema script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:32:48 -05:00
196 changed files with 36447 additions and 303 deletions

No files matched your search

+11 -18
View File
@@ -1,21 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.5.0
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:
build:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+20
View File
@@ -0,0 +1,20 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+25
View File
@@ -0,0 +1,25 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
with:
runner: linux-small
docker-runner: container-build
browsers: true
browser-runner: browser-build
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
+72
View File
@@ -0,0 +1,72 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
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 release run id (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
permissions:
contents: read
jobs:
release-rc:
# The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch
# completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus
# merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry).
permissions:
contents: write
actions: read
pull-requests: write
if: github.event_name == 'workflow_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: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ 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: ${{ inputs.merge_sha || '' }}
bump_sha: ${{ inputs.bump_sha || '' }}
branch: ${{ inputs.branch || '' }}
version: ${{ inputs.version || '' }}
secrets:
QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }}
+25
View File
@@ -0,0 +1,25 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
+23
View File
@@ -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
+74
View File
@@ -0,0 +1,74 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
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
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
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 || 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 -15
View File
@@ -1,20 +1,21 @@
# Maven
target/
.env
.idea
*.iml
*.class
*.log
*.jar
*.war
*.nar
*.ear
# IDE
.idea/
*.iml
.vscode/
.project
.classpath
.settings/
# OS
*.zip
*.tar.gz
*.rar
hs_err_pid*
.DS_Store
Thumbs.db
# Logs
*.log
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+16
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
# AGENTS.md
This file provides guidance to Codex when working with code in this repository.
## Project Overview
**QBit Data Analysis**: statistical analysis and data dives on any table, filter or saved view. Design: [QRun-IO/qqq#1137](https://github.com/QRun-IO/qqq/pull/1137), `docs/DATA-ANALYSIS.md` in QQQ. Repository phase: metadata, permissions and Analyses home summary. Computation and UI phases a-f are planned.
## Build Commands
```bash
./mvnw compile
./mvnw test
./mvnw verify # Checkstyle, tests, JaCoCo: 70% instructions, 90% classes
```
Always use the wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
## Architecture
Entry point: `DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalysisQBitConfig>` in `com.kingsrook.qbits.dataanalysis`. Component producers are scanned only in `metadata`. Tables live in `model`, widgets in `app`, permission helpers in `permissions`.
## Invariants
- Queries use the current user's session and enforced permissions.
- Saved analyses remain owner-locked; the shared flag does not broaden READ yet. See README Security.
- Hosts populate `data_analysis_user` with the session user id; only administrators get the `data_analysis.admin` all-access security key.
- Real counts only. Schedules and stars are children of saved analyses. No processes in navigation.
## Naming
- Tables: `data_analysis_<name>`; SQL names equal QQQ table names. Table PVS use the table name.
- Permissions: `data_analysis.saved.*`, `data_analysis.watch.*`, `data_analysis.settings.*`, `data_analysis.run.*` (standard rules, no custom checker), `data_analysis.admin` (lock all-access key), plus `<effective table permission base>.analyze` per analyzed table.
- Data lives in visible standard QQQ tables with permissions and record locks.
## Code Style
- Copy the Apache header from existing Java files; 3-space indent, Allman braces, `return (x);`.
- Flower-box Javadoc on every class and method; fluent `withX()` setters.
- Wrapper types in config and entities; JUnit 5 and AssertJ tests.
## Dependencies
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ `4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); remove activation when the parent moves to 4.1. Middleware Javalin is optional. RDBMS, H2, PostgreSQL driver and Testcontainers are test-only for later pushdown tests. Later phases need core aggregate extensions and the UI API extension point (#1128, RC.6).
## Release
CircleCI qqq-orb 0.6.8: `develop` publishes SNAPSHOTs, `release/*` publishes RCs (orb version bumps), `vX.Y.Z` publishes releases. No manual publishing.
+75
View File
@@ -0,0 +1,75 @@
# Changelog
Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
## [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
- 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.
- Standard statistics/chart/table dashboard widgets, linked saved analysis widgets and real home rows with narrow tables and honest empty states.
- Extension 1.4.0, seven new assistant-discoverable endpoints, regenerated OpenAPI and memory/H2/HTTP/export/email/security regression coverage.
- Migration requirement: backfill saved `read_owner_user_id` before enabling shared reads and widen schedule `last_status` to TEXT. Stock core EMAIL attachment limitations fail visibly; supported messaging providers receive full attachments.
- Phase e anomaly watches: owner-locked configuration, validated metrics/detector parameters, complete calendar buckets, five pure detectors, and scheduled evaluation through owner sessions.
- Core alerts SPI type `data.analysis.anomaly` (core disallows the spec's underscore), stable watch keys, relative drill links, raise/recovery transitions, and persisted outcomes without an alerts implementation.
- Standard streamed ETL Evaluate now / Enable / Disable record actions and firing-first linked watches on the Analyses home.
- Read-only watch preview HTTP endpoint, semantic chart/baseline annotations, extension 1.3.0 and regenerated OpenAPI fragment.
- Detector edge cases, real-row owner/security/alert transition tests, calendar boundary and action tests, and HTTP preview coverage.
- Phase d compare, shift, correlation, scatter and crosstab service operations and HTTP specs; extension version 1.2.0 with typed `DataAnalysis*V1` schemas and regenerated OpenAPI.
- Pure-Java two-proportion, Welch, chi-square/Cramer V, PSI and Pearson statistics, including regularized incomplete beta/gamma and independent SciPy references.
- Core-pushed numeric moments and batched correlations, sample trend lines, calendar distribution shifts, category pooling, privacy suppression and exact count drills.
- Shared memory/H2/PostgreSQL relationship contracts, forced core streamed-fallback checks, batching checks and HTTP authorization/serialization/drill tests.
- Phase c pivot backend and `POST /qbit/data-analysis/tables/{table}/pivot`, extension version 1.1.0, typed schemas and regenerated OpenAPI fragment.
- Row/column dimensions, calendar/numeric buckets, seven measure operators, sparse cells, exact Other recomputation, totals, privacy suppression and count-equivalent drills.
- Exposed-join dimensions and measures with READ/FULL checks; filter-only drills for to-one paths and explicit null-drill warnings for to-many paths whose joined-row counts can exceed record counts.
- Configurable field-access resolver, cached reflective core tier bridge with legacy fallback, FULL-only capabilities/selections/labels, generated-drill protection and field-access cache fingerprints.
- Shared memory/H2/PostgreSQL pivot contracts, HTTP drills and errors, reflective resolver/fallback and tier-change cache tests.
- Session-bound profile, capability, distinct-value, duplicate and run endpoints, typed schemas and a checked OpenAPI fragment (introduced in extension 1.0.0).
- Core table-feature permission registration and capability-directed aggregate pushdown, with native sampling and portable streamed fallbacks.
- HTTP authorization, drill-to-core-count round trips, async polling, served OpenAPI and assistant discovery tests; H2 streamed and native-compatibility contract variants.
### 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.
- Empty-string and temporal drills round-trip through core HTTP filter interpretation.
### Previously added
- Phase b host engine: typed column/table profiles, distinct pages, duplicates, count-equivalent drills, chart data, candidate keys and completeness heatmaps.
- Internal `AnalysisBackend` port and RC.5 stand-in with aggregate pushdown, bounded query pages, deterministic prefix estimates and honest truncation metadata.
- Personalized field/table authorization, enforced FK label lookup, runtime configuration overrides and minimum-group-size privacy before caching.
- Owner-scoped exact runs, asynchronous session propagation, normalized SHA-256 request identities and data-version/TTL cache reuse.
- Shared memory/H2/PostgreSQL contract tests for metric references, drill equivalence, permissions, locks, privacy, estimates, jobs and cache behavior. PostgreSQL tests skip when Docker is unavailable.
- Repository scaffold; analysis settings, saved analyses, schedules, stars, watches and runs tables; permissions; Analyses app home.
+41 -10
View File
@@ -1,17 +1,48 @@
# qbit-template-extension
# CLAUDE.md
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
This file provides guidance to Claude Code when working with code in this repository.
## QQQ 4.0 baseline — 2026-09-24
## Project Overview
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
**QBit Data Analysis**: statistical analysis and data dives on any table, filter or saved view. Design: [QRun-IO/qqq#1137](https://github.com/QRun-IO/qqq/pull/1137), `docs/DATA-ANALYSIS.md` in QQQ. Repository phase: metadata, permissions and Analyses home summary. Computation and UI phases a-f are planned.
## Knowledge base
## Build Commands
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
```bash
./mvnw compile
./mvnw test
./mvnw verify # Checkstyle, tests, JaCoCo: 70% instructions, 90% classes
```
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
Always use the wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
## Architecture
Entry point: `DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalysisQBitConfig>` in `com.kingsrook.qbits.dataanalysis`. Component producers are scanned only in `metadata`. Tables live in `model`, widgets in `app`, permission helpers in `permissions`.
## Invariants
- Queries use the current user's session and enforced permissions.
- Saved analyses remain owner-locked; the shared flag does not broaden READ yet. See README Security.
- Hosts populate `data_analysis_user` with the session user id; only administrators get the `data_analysis.admin` all-access security key.
- Real counts only. Schedules and stars are children of saved analyses. No processes in navigation.
## Naming
- Tables: `data_analysis_<name>`; SQL names equal QQQ table names. Table PVS use the table name.
- Permissions: `data_analysis.saved.*`, `data_analysis.watch.*`, `data_analysis.settings.*`, `data_analysis.run.*` (standard rules, no custom checker), `data_analysis.admin` (lock all-access key), plus `<effective table permission base>.analyze` per analyzed table.
- Data lives in visible standard QQQ tables with permissions and record locks.
## Code Style
- Copy the Apache header from existing Java files; 3-space indent, Allman braces, `return (x);`.
- Flower-box Javadoc on every class and method; fluent `withX()` setters.
- Wrapper types in config and entities; JUnit 5 and AssertJ tests.
## Dependencies
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ `4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); remove activation when the parent moves to 4.1. Middleware Javalin is optional. RDBMS, H2, PostgreSQL driver and Testcontainers are test-only for later pushdown tests. Later phases need core aggregate extensions and the UI API extension point (#1128, RC.6).
## Release
CircleCI qqq-orb 0.6.8: `develop` publishes SNAPSHOTs, `release/*` publishes RCs (orb version bumps), `vX.Y.Z` publishes releases. No manual publishing.
+3
View File
@@ -0,0 +1,3 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
+3
View File
@@ -0,0 +1,3 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
+5
View File
@@ -0,0 +1,5 @@
# Contributing
Follow [QQQ contributing guidance](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md).
Use Java 21 and `./mvnw verify`, and follow local Checkstyle. Create feature branches from `develop` and target `develop` with PRs. See [AGENTS.md](AGENTS.md) for architecture, naming, security and release conventions, and [the design](https://github.com/QRun-IO/qqq/pull/1137) before extending the qbit.
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2022-2025 QRunIO, LLC
Copyright 2024 Kingsrook, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -3
View File
@@ -1,6 +1,6 @@
qBit Template - Extension
Copyright 2022-2025 QRunIO, LLC
QBit Data Analysis
Copyright 2026 Kingsrook, LLC
This product includes software developed at QRunIO, LLC (https://qrun.io/).
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
Licensed under the Apache License, Version 2.0.
+323 -27
View File
@@ -1,42 +1,338 @@
# QBit Template: Extension
# QBit Data Analysis
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
Statistical analysis and data dives on any QQQ table, filter or saved view: profiles, pivots, comparisons, anomalies, and saved and scheduled analyses. The approved [design is QRun-IO/qqq#1137](https://github.com/QRun-IO/qqq/pull/1137), `docs/DATA-ANALYSIS.md` in QQQ; [DESIGN.md](docs/DESIGN.md) includes its phase table.
## What is an Extension QBit?
## What works now
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
- New backend implementations
- Authentication mechanisms
- Audit capabilities
- Table customizers
- Action handlers
The host API provides column and table profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs, distinct-value pages, duplicate groups, drill-through filters, privacy suppression, and asynchronous exact profile runs with a persistent per-user cache. Metadata, six visible tables, owner locks, PostgreSQL DDL generation and the Analyses home are also available.
## Quick Start
Twenty-one HTTP operations are available under `/qqq/v1/qbit/data-analysis` (extension **1.4.0**). Saved execution, shared reads, personal stars, scheduled email, CSV/XLSX exports, SQL Workbench handoff and standard dashboard widgets are implemented. The separate Analyze/pivot/relationship frontend remains outside this repository.
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
## Requirements
## Structure
- 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.
```
src/main/java/com/kingsrook/qbits/example/
├── ExampleExtensionQBitConfig.java # Configuration
├── ExampleExtensionQBitProducer.java # Entry point
└── customizers/
└── ExampleTableCustomizer.java # Table customization
## Install
Use `com.kingsrook.qbits:qbit-data-analysis:0.1.0-SNAPSHOT` once published, or build locally. After adding your backend to the instance, install the producer:
```java
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitProducer;
new DataAnalysisQBitProducer()
.withQBitConfig(new DataAnalysisQBitConfig()
.withBackendName("rdbms")
.withAppName("dataAnalysis")
.withAppLabel("Analyses"))
.produce(qInstance);
```
## Key Characteristics
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.
- **No QAppSection** - Pure infrastructure
- **No user-facing tables** (or minimal operational)
- **Wraps existing tables/processes** with new behavior
- **Validates dependencies** on other QQQ components
## Starter defaults and setup
## Documentation
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.
- [Getting Started](docs/00-getting-started.md)
- [Extension Patterns](docs/01-extension-patterns.md)
- 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.
| Table | Purpose and navigation |
| --- | --- |
| `data_analysis_table_config` | Analysis Settings: per-table runtime overrides, unique `table_name`; field audit |
| `data_analysis_saved` | Saved Analyses: typed definitions, owner, shared flag; field audit |
| `data_analysis_saved_star` | Stars: child list, unique (`saved_id`, `user_id`) |
| `data_analysis_schedule` | Schedules: child list with cron/frequency, delivery settings and run state |
| `data_analysis_watch` | Watches: metric, detector, parameters and observed state; links to saved analysis |
| `data_analysis_run` | Analysis Runs: exact-run/cache result and history, scoped by user |
Only Saved Analyses, Watches, Analysis Settings and Analysis Runs appear in navigation. Schedules, stars and linked watches appear on saved records. Enum PVS cover analysis kind, chart kind, detector, watch state, run status, schedule frequency and format. JSON definitions and results are stored as text. Profile, distinct-value and duplicate requests have typed Java contracts and are validated by the facade.
The four `table_name` fields use core's `tables` possible-value source; an existing host source is preserved. A watch's `saved_id` options are filtered by its selected `table_name`. Analysis Settings' `analyzable_fields` and `excluded_fields` use a custom field picker scoped to the selected table. Core currently has no multi-select PVS field, so these settings use a single PVS with comma-separated TEXT storage (for example, `amount,status`); the configuration resolver continues to parse those lists. Explicit dependency metadata awaits core's `possibleValueSourceDependsOn` support.
## Permissions
| Permission | Grants |
| --- | --- |
| `data_analysis.saved.read`, `.insert`, `.edit`, `.delete` | Corresponding actions on saved analyses, stars and schedules |
| `data_analysis.watch.read`, `.insert`, `.edit`, `.delete` | Corresponding watch actions |
| `data_analysis.settings.read`, `.insert`, `.edit`, `.delete` | Analysis Settings (per-table configuration) |
| `data_analysis.run.read`, `.insert`, `.edit`, `.delete` | Analysis Runs (run history and cached results, still subject to the user lock) |
| `data_analysis.admin` | The all-access key of the `data_analysis_user` record locks (see Security) |
| `<effective table permission base>.analyze` | Analyze a source table, also requiring its READ permission |
`DataAnalysisPermissions.analyzePermissionName(table)` honors the effective table permission base. `canAnalyze(table)` delegates to core's table-feature permission check. The producer registers `analyze`, labelled **Analyze**, on configured source tables, excluding this qbit's own tables. No CRUD permission implies analyze. Core checks READ as well as the feature grant on protected tables. All analysis queries enforce permissions even when the instance default is off. Qbit CRUD permissions also appear in the core catalog.
## Security
Every engine operation and home count runs under the current `QContext` session with `PermissionEnforcement.ENFORCE`. A denied section explains the missing permission instead of showing zero. Mine and starred counts explicitly filter the current user even for administrators. Shared counts include only readable shared rows. Stars pointing at unreadable or deleted saved analyses do not count. Run counts cover starts within the last seven days, excluding future rows.
Saved analyses have a WRITE lock on `owner_user_id` and a separate READ lock on the hidden `read_owner_user_id` projection. Core pre-insert/update customizers set that projection to the owner for private rows and null for shared rows; the READ lock allows null. This expresses **owner OR shared READ**, while core retains owner-only writes and administrator all-access keys. Stars and runs have locks on `user_id`. Schedules inherit the saved analysis owner's lock through their join. Null owners are denied. The host's authentication/session adapter must supply:
```java
session.withSecurityKeyValue("data_analysis_user", session.getUser().getIdReference());
// Only after the host has authenticated and authorized an administrator:
session.withPermission("data_analysis.admin")
.withSecurityKeyValue("data_analysis.admin", true);
```
QQQ permissions and security-key values are separate. An admin permission alone does not bypass record locks. User-id fields must use the same stable string identity as `QUser.idReference`.
**Shared READ is enabled through core record locks.** A viewer still needs `data_analysis.saved.read`. Query/get/count, standard CRUD, saved runs, lists and exports use core enforcement. Shared viewers cannot edit, delete, change ownership, or schedule another owner's analysis. No custom query filter grants access. Sharing exposes the saved definition and metadata, not the owner's data: every run computes under the viewer's source-table READ/Analyze permissions and field policy. There is no per-recipient/group sharing or access-request workflow yet; collaboration's sharing contract ([qqq#1136](https://github.com/QRun-IO/qqq/issues/1136)) will replace the local flag. Stars move to `collaboration_favorite` when collaboration ships.
**Existing databases must migrate before enabling the new metadata.** A missing/null read-owner projection would make an old private row readable. Apply this through the host's migration system while writes are stopped, before deploying this version:
```sql
ALTER TABLE data_analysis_saved ADD COLUMN IF NOT EXISTS read_owner_user_id VARCHAR(250);
UPDATE data_analysis_saved
SET read_owner_user_id = CASE WHEN shared IS TRUE THEN NULL ELSE owner_user_id END;
ALTER TABLE data_analysis_schedule ALTER COLUMN last_status TYPE TEXT;
```
The metadata-generated create script includes these fields for new installations; it does not migrate existing databases. Fix older placeholder definitions (such as `{}` for a column profile) to valid typed requests. Edit existing enabled schedules through core CRUD to validate recipients and initialize `next_run_at`.
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.
Cached results are scoped to the authenticated user, normalized request, effective policy, session permissions/security keys/backend variants/time zone, and data version. Access and policy are checked again when retrieving runs. Hosts must grant `data_analysis.run.read`, `.insert` and `.edit` to analysis users for persisted profiles and exact jobs. Run errors contain safe text rather than backend data or query details.
## Configuration
| Setting | Default |
| --- | --- |
| `backendName` | Required, must exist in the instance |
| `schedulerName` | Optional; reserved for scheduled delivery |
| `appName`, `appLabel` | `dataAnalysis`, `Analyses` |
| `parentAppName` | None |
| `defaultMaxRows` | 1,000,000 |
| `defaultSampleThresholdRows` | 1,000,000 |
| `defaultSampleRows` | 100,000 |
| `defaultMinGroupSize` | 0 |
| `defaultCacheTtlMinutes` | 60 |
| `includeTables` | Empty set means all tables |
| `excludeTables` | Empty set |
| `tableDefaults` | Empty map of table names to `TableAnalysisDefaults` |
All properties have fluent `withX` setters. Global numeric defaults must be non-null and non-negative. Nullable per-table numeric overrides inherit global defaults; configured values must be non-negative. `TableAnalysisDefaults` also supports enabled, analyzable fields and excluded fields. Runtime `data_analysis_table_config` values override per-table code defaults, which override global defaults. Null row values inherit; blank field lists mean all readable fields or no exclusions. Include/exclude table lists remain an outer restriction, even if a runtime row enables the table. Only the config-row lookup uses a temporary system session, restored in `finally`. It selects configuration fields only and is memoized for 60 seconds. Settings editors can call `service.getConfigResolver().invalidate()` for immediate refresh.
`max_rows` bounds the stand-in's selected population and retained records. An exact request disables sampling but still respects this bound and reports `truncated: true` when necessary; increase the limit to obtain a complete larger profile. Zero selects no rows in the stand-in. Above `sample_threshold_rows`, capable backends run each aggregate with `samplePercent = min(100, sample_rows * 100 / total)` on the user's filter. Counts, top values and histograms are scaled by the full population divided by the observed sample count for that call; ratios and descriptive statistics remain in sample units. The `rows` metric always reports the unsampled total for estimates. Other metrics carry `estimate: true` and an `≈ ` formatted prefix. Sampling never materializes a primary-key IN filter. Backends without sampling use one `pk <= boundary` criterion to compute an unscaled first-N prefix estimate. Estimate drills combine the original filter with the metric condition over the full population, so count/drill equivalence applies only to exact results. Warnings distinguish random samples from prefixes; `sampleRows`, `rowsScanned` and `asOf` describe coverage. `cache_ttl_minutes: 0` disables cache reuse.
## Host API
Call the facade with the authenticated user's `QContext` installed. The producer registers an instance-scoped runtime:
```java
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesRequest;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesRequest;
var service = DataAnalysisRuntime.service();
var capabilities = service.capabilities("orders");
var column = service.columnProfile(new ColumnProfileRequest()
.withTableName("orders").withFieldName("amount").withFilter(filter));
var table = service.tableProfile(new TableProfileRequest()
.withTableName("orders").withFilter(filter));
var values = service.distinctValues(new DistinctValuesRequest()
.withTableName("orders").withFieldName("status").withPage(0).withPageSize(100));
var duplicates = service.duplicates(new DuplicatesRequest()
.withTableName("orders").withFields(java.util.List.of("customer", "status")));
String runId = service.startExactRun(new ColumnProfileRequest()
.withTableName("orders").withFieldName("amount").withFilter(filter));
var status = service.getRun(runId); // RUNNING, COMPLETE or FAILED; typed result on COMPLETE
```
`startExactRun` also accepts `TableProfileRequest`. Identical fresh exact requests reuse the existing COMPLETE row and id. A host may construct `DataAnalysisService(config, executor)` to use its managed executor; the default is the common pool. Workers receive a cloned session and instance context, with no inherited transaction or privileged action scope.
The `result` package contains Serializable, Jackson-friendly request/result POJOs, semantic metrics, charts and drills. There are no frontend-specific fields. Distinct pages are zero-based with a maximum size of 500; search matches stored values. Guarded distinct groups are merged before pagination. Shares are fractions from 0 to 1. Numeric standard deviation is the sample statistic; percentiles match `percentile_cont` interpolation at `p * (n - 1)`. Date buckets use the session time zone (including DST), ISO Monday weeks and the specified 92-day/two-year/ten-year thresholds. Date range metrics are in days. Text shapes use at most the first 10,000 rows and state that sample size.
Count drills combine an untouched copy of the request filter with their producing condition. Cardinality drills select one representative record per distinct non-null value; the privacy guard removes those representative drills. Truncated profiles retain their primary-key boundary in drills, so their counts remain reproducible. Serialized drill getters restore temporal operand types from the current instance metadata before core evaluates them.
## HTTP endpoints
Extension `data-analysis` version **1.3.0**, prefix `/qbit/data-analysis` inside `/qqq/v1`:
| Method | Path after `/qqq/v1` | Body or purpose |
| --- | --- | --- |
| GET | `/qbit/data-analysis/tables/{table}/capabilities` | Availability, fields and charts, limits, backend capabilities, SQL Workbench presence |
| POST | `/qbit/data-analysis/tables/{table}/column-profile` | `{fieldName, filter, exact}` |
| POST | `/qbit/data-analysis/tables/{table}/distinct-values` | `{fieldName, filter, page, pageSize, search}` |
| POST | `/qbit/data-analysis/tables/{table}/table-profile` | `{filter, fields}` |
| POST | `/qbit/data-analysis/tables/{table}/duplicates` | `{fields, filter, limit}`; default limit 50, maximum 500 |
| POST | `/qbit/data-analysis/tables/{table}/pivot` | `{rows, columns, values, filter, showTotals, limitGroups}`; 1–3 row dimensions, 0–2 column dimensions, 1–6 measures |
| POST | `/qbit/data-analysis/tables/{table}/compare` | `{filterA, filterB, labelA, labelB, fields}`; null filter selects the whole permitted table |
| POST | `/qbit/data-analysis/tables/{table}/shift` | `{field, dateField, bucket, filter}`; calendar buckets, top 8 categories or quartiles, first/last complete-bucket PSI |
| POST | `/qbit/data-analysis/tables/{table}/correlation` | `{fields, filter}`; 2–12 distinct numeric fields, pairwise r and n, scatter request per cell |
| POST | `/qbit/data-analysis/tables/{table}/scatter` | `{xField, yField, filter, limit}`; default/maximum 2000 points and a sample trend line |
| POST | `/qbit/data-analysis/tables/{table}/crosstab` | `{rowField, columnField, filter, limit}`; default/maximum 20 categories per axis plus Other |
| POST | `/qbit/data-analysis/runs` | `{operation: COLUMN_PROFILE or TABLE_PROFILE, tableName, request}` returns `{runId, status}` |
| GET | `/qbit/data-analysis/runs/{runId}` | Status, timestamps, error, typed `columnProfile` or `tableProfile` |
| POST | `/qbit/data-analysis/watches/{id}/preview` | No body; complete bucket series, `TIME_SERIES` chart with baseline annotations and detector verdict; no writes or alerts |
Every operation uses the request session through `DataAnalysisService`. Capabilities returns 200 with `canAnalyze: false` and no fields when denied. Other permission denials return 403, bad bodies or unavailable fields return 400, and unknown tables return 404. Core middleware supplies plain messages in its standard `{error: message}` JSON envelope. Run rows remain owner-locked.
Requests and drills reuse core's `QueryFilter` JSON shape and schema reference. Versioned `DataAnalysis...V1` schemas cover profiles, metrics, drills, charts, series, points, field summaries, distinct and duplicate results, capabilities and runs. The [OpenAPI fragment](src/main/resources/openapi/qqq-v1/data-analysis.yaml) is checked against regeneration. The deployed `/qqq/v1/openapi.json` and `UiApiInvoker.listOperations()` include all fourteen operations.
Regenerate deliberately after an API change:
```bash
./mvnw -q test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true
```
## Pivot backend and field access
`DataAnalysisService.pivot(PivotRequest)` returns sparse cells indexed into row/column header tuples, aligned totals, typed/formatted metrics, warnings and `asOf`. `COUNT` counts records (including null measure values); other operators require a field. `PERCENTILE` uses continuous interpolation with a fraction from 0 to 1. Calendar buckets use the session timezone. Numeric bins reuse profile nice-edge selection when explicit bounds are omitted; the last interior bin includes its maximum.
The planner first requests count-ordered top-N groups for every dimension (default 50, maximum 200), then dimension counts for privacy/cardinality, one grouped matrix and one aggregate per totals level. Additional aggregates recompute merged Other cells/totals: averages, distinct counts and percentiles cannot safely be combined from displayed cells. Ordinary Other predicates exclude kept values and handle null/empty strings separately. Boolean, temporal and bucketed Other groups use equivalent membership/range predicates for core HTTP compatibility. Cyclic date predicates enumerate observed values through core grouping; memory numeric boundaries reuse the profiles' decimal-scale compatibility helper. Native pivots are exact; bounded core streamed fallback marks partial results as estimates and disables their drills.
`joinTable.field` resolves only through an unambiguous exposed join path. Every table in that path requires READ and the selected field must be FULL; core enforces joins and record locks. The installed data-analysis snapshot does not contain the related-record picker path API, so this implementation uses `getExposedJoins()` and `QueryJoin`. Paths whose every hop is MANY_TO_ONE or ONE_TO_ONE in the analyzed direction support ordinary filter-only drills: core infers the joins from qualified criteria. To-many paths can multiply a base record, so their pivots retain null drills and warn that counts include one row per related table record.
`min_group_size` suppresses rare dimension groups and rare row/column tuples; every measure of a small cell or total is suppressed, including COUNT, with no drill. Totals include the full authorized population. As with existing profiles, separate aggregate calls are not a transactional snapshot.
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 pinned RC.7 exercises the actual core tier resolver selection. Exact-run persistence continues to support profiles only; pivot results are synchronous.
## Backend selection
`DataAnalysisService` selects `CoreAnalysisBackend` when the source backend advertises aggregate capabilities, including memory. Supported standard deviation, percentiles, null/blank counts, numeric/date buckets and length statistics execute through core aggregates as the current session. Unsupported primitives delegate to `StandInAnalysisBackend`. Patterns and derived length histograms remain streamed; representative distinct drills and cyclic-date membership use bounded scans.
The installed snapshot requires H2 PostgreSQL compatibility mode for native bucket SQL (`GROUP BY` positions).
`engine.AnalysisBackend` isolates profilers from backend-specific operators. `StandInAnalysisBackend` pushes down COUNT, COUNT_DISTINCT, MIN, MAX, AVG, SUM, grouped counts, aggregate ordering and limits through core actions. RC.5 has no aggregate OFFSET, so pages over-fetch through the requested end and slice locally. Memory grouping pagination and scale-sensitive decimal equality have isolated compatibility handling.
Variance, interpolated percentiles, string lengths/shapes, date classification, whitespace detection and representative-row drills use bounded primary-key query pages. Histogram/date membership counts use enforced range/count queries after their boundaries have been calculated. Numeric histograms have 20 interior bins between p1 and p99 plus below/above bins, and the last interior bin is closed. Profiles include Tukey outliers, date gaps, date-time weekday/hour patterns, chart alternatives, a seven-bucket moving average, table completeness and candidate keys (single fields and pairs among the eight most unique).
Data versions use filtered COUNT plus MAX of a readable modification-date-like field, otherwise MAX(primary key). This is the specified inexpensive invalidation heuristic, not snapshot isolation; updates on a table without a modification timestamp can remain cached until TTL expiry. Concurrent writes can also change drill counts after a profile is produced. Results always retain `asOf`.
## Compare and relationships
All five operations resolve FULL fields before reading source data and use core actions with `PermissionEnforcement.ENFORCE`. Core applies table permissions and record locks. Compare batches numeric COUNT, AVG, VAR_SAMP and quartiles per population; categorical comparisons use a common top-10-plus-Other distribution and pool categories with expected counts below five. Deltas are B minus A, and percent deltas divide by absolute A (null when A is zero). Significance uses a strict p < 0.05 threshold; invalid or privacy-suppressed tests return a plain reason.
Correlation batches all CORR pairs into one aggregate call. Pairwise non-null n values use enforced core count actions, since the current aggregate API has no per-measure filter. Unsupported aggregates use core's bounded streamed fallback; a truncated fallback returns 400 with a request to narrow the filter, rather than publishing a partial significance test. Scatter uses the first N paired records ordered by primary key because the current core QueryInput has no sampling API. Its least-squares line describes that displayed sample. Point drills contain only primary-key equality and are disabled whenever privacy is enabled or the primary key is not FULL.
Shift uses session-timezone calendar buckets. Only buckets whose end has passed enter PSI; categories share one top-8-plus-Other basis, and numeric PSI uses the first complete bucket's quartile boundaries for both populations. Zero shares are floored at 0.000001. PSI is descriptive, not a significance test. Category time series explicitly declare `stacked: true`.
The minimum-group-size guard removes small numeric summaries, suppresses crosstab cells and revealing marginal shares, disables drills on privacy-merged Other groups, and suppresses scatter points below the minimum. Statistical tests and PSI are unavailable when protected values would contribute. No runtime statistics dependency is added; beta/gamma, Welch, chi-square, proportions, PSI and Pearson are pure Java, checked against independently computed SciPy references.
## Anomaly watches
Watches are ordinary owner-locked `data_analysis_watch` rows. Create and edit them through core table CRUD with `data_analysis.watch.read`, `.insert`, `.edit`, and `.delete`. Standard table/record actions **Evaluate now**, **Enable**, and **Disable** use streamed ETL Review and Result summaries. They never appear in navigation. The home lists firing watches first and links to their records.
A watch has `owner_user_id`, `date_field`, `bucket` (`HOUR`, `DAY`, `WEEK`, `MONTH`), `detector`, `parameters_json`, `severity` (`INFO`, `WARNING`, `ERROR`, `CRITICAL`) and `enabled`. It targets either:
- `table_name`, optional core `filter_json`, and `metric_json`, such as `{"operator":"COUNT"}`, `{"operator":"SUM","field":"amount"}`, `{"operator":"AVG","field":"amount"}`, `{"operator":"NULL_SHARE","field":"email"}`, or `{"operator":"CATEGORY_SET","field":"status"}`.
- `saved_id`: the owner's saved table and filter, with an explicit watch `metric_json` or one saved definition `metric` / single pivot `values` measure. The date field and bucket remain explicit on the watch. Ambiguous saved measures are rejected; saved filters cannot be overridden by `filter_json` on the watch.
Detector parameters are validated by core pre-insert/update customizers, including partial updates. Unknown parameters and invalid metric/detector combinations return clear record errors.
| Detector | Parameters and behavior |
| --- | --- |
| SPIKE / DROP | `k=14`, `threshold=3.5`, `relativeChange=0.5`; robust z = `0.6745 * (latest - median) / MAD`, strict directional threshold. With zero MAD, compare relative change against the absolute median. A zero baseline fires only for a nonzero change in the requested direction. |
| OUT_OF_RANGE | Required `min` and `max`; fire outside the inclusive range. Needs only the latest bucket. |
| NEW_CATEGORY | `k=14`; requires `CATEGORY_SET`; compare latest categories with the union of previous buckets. Null is excluded from the category set. |
| NULL_RATE_CREEP | `k=14`, `delta=0.05`, `n=3`; requires `NULL_SHARE`; fire for a rise above the baseline median greater than delta, or `n` consecutive increases (`n+1` values). |
`k` and `n` are bounded to 1–1000. The trailing window contains at most `k+1` complete buckets, excludes the current bucket, and follows the owner's time zone (Monday-based weeks). Leading buckets with no observed rows do not invent history. After the first observed bucket, missing COUNT/SUM/category buckets are zero/empty; AVG and NULL_SHARE with no population are unavailable. Too little history, incomplete streamed results, unavailable fields, and privacy-suppressed buckets never cause a recovery. No suppressed series, categories, values or drills leave the computation. All source reads enforce READ, Analyze, record locks and FULL field access, including filter fields.
`data_analysis_evaluate_watches` is hidden and repeats every 900 seconds when `schedulerName` is configured. Give the scheduler session its standard process permission, `data_analysis_evaluate_watches.hasAccess`. Inventory and status writes use grants restricted to watch rows; source aggregation always switches to the owner. The default integration calls the host authentication module's `createAutomatedSessionForUser`. Hosts can instead configure `withOwnerSessionSupplier(userId -> session)`. The session must identify that owner and include the `data_analysis_user` owner key plus their current permissions. Missing/mismatched sessions record **Owner session unavailable**; there is no system-session fallback. Manual Evaluate now can also evaluate a disabled watch.
A transition from OK to FIRING raises through core `QAlerts`, with the watch record reference and a relative table-query `AlertLink`; recovery resolves the same `data_analysis_watch:<id>` key. The declared type is **`data.analysis.anomaly`**, labelled **Data anomaly**. This spelling is necessary because the supplied core SPI validator rejects the design's `data_analysis.anomaly` underscore. No qbit-alerts dependency is required. With no sink installed, core logs the signal and the watch row still records the outcome. Repeated FIRING/OK evaluations do not send duplicate transitions. Calls are serialized within each runtime; hosts should run this scheduler on one node (cross-node transactional coordination is not provided).
`state`, `alert_key`, `last_evaluated_at`, `last_value` and `last_message` describe the most recent attempt. Skips clear the last value and leave the existing OK/FIRING state intact. Preview uses the requesting viewer's permissions and never updates these fields.
`DataAnalysisSchema.postgresScript(config)` generates the new fields and owner requirement. Existing installations must add `date_field VARCHAR(250)`, `owner_user_id VARCHAR(250)`, `last_value NUMERIC`, and `last_message TEXT`, then backfill each watch's actual owner/date field before making the first two columns NOT NULL and enabling evaluation. The CREATE-IF-NOT-EXISTS generator does not migrate existing tables.
## Saved analyses, stars and exports
Create/edit saved analyses through ordinary core table CRUD. `kind` selects `COLUMN_PROFILE`, `TABLE_PROFILE`, `PIVOT`, `COMPARE`, `CORRELATION`, `CROSSTAB` or `SHIFT`; `definition_json` stores that kind's typed request. For example, a column profile has `{"fieldName":"amount","filter":{...}}`, and a pivot has `{"rows":[{"field":"status"}],"values":[{"operator":"COUNT"}]}`. `table_name` identifies the source table; a supplied `tableName` in the JSON must match. The customizers reject malformed JSON, missing selections, nonexistent tables/fields and invalid partial edits with plain record errors. A typed definition filter takes precedence over legacy `filter_json`.
`POST /saved/{id}/run` re-runs as the viewer and returns `{kind, warnings, columnProfile, tableProfile, pivot, compare, correlation, crosstab, shift}`, with the matching typed member populated. Selected fields without FULL analysis access are removed and `Some fields are not available to you` is returned. If a required axis, all required selections, or a filter predicate is unavailable, no computation is performed and the typed members are null; filters are never dropped to broaden the population. Saved profile runs are transient, so they do not require run-table write permission and cannot reuse an owner's cache.
`GET /saved` lists core-visible saved metadata with each viewer's `starred` flag. `PUT /saved/{id}/star` and `DELETE /saved/{id}/star` are idempotent and always target the signed-in user, using standard saved INSERT/DELETE grants. The local star table remains available through ordinary CRUD and has a unique `(saved_id,user_id)` key.
`GET /saved/{id}/export?format=csv|xlsx` re-runs as the viewer. `POST /export` accepts a discriminated request:
```json
{"kind":"PIVOT","tableName":"orders","request":{"rows":[{"field":"status"}],"values":[{"operator":"COUNT"}]},"format":"xlsx"}
```
Both routes return file bytes with attachment headers. Core `ReportFormat` CSV/XLSX streamers write computed result tables: profile metrics/top values, pivot grids, comparisons, correlation/crosstab tables and shift buckets. Values retain their formatting and privacy suppression. Warnings are included in downloads. All paths above are relative to `/qqq/v1/qbit/data-analysis` and appear in assistant operation discovery.
## Scheduled delivery
Configure `schedulerName`, `messagingProviderName`, `fromAddress`, `ownerSessionSupplier` and `recipientAddressSupplier` in `DataAnalysisQBitConfig`. The session supplier resolves both owners and recipients by their stable user id; without it, the authentication module's `createAutomatedSessionForUser` is used. The address supplier maps those ids to verified directory email addresses; schedule recipients are **user ids**, never arbitrary email addresses.
Schedules use `recipients` as a JSON array (1–100 ids), `format` as `HTML_SUMMARY`, `CSV` or `XLSX`, and either Quartz cron (including the seconds field) or `frequency` + `day` + `time` + `time_zone`. Weekly days are ISO Monday=1 through Sunday=7; monthly days are 1–31, clamped to the last day of shorter months. Times are local ISO times such as `09:00`; omitted time zone is UTC. Spring-forward gaps move forward, and the first occurrence is chosen in an autumn overlap. `next_run_at` is strictly after the evaluation time.
The hidden `data_analysis_run_schedules` process repeats every 300 seconds when a scheduler is configured. Its scheduler session needs `data_analysis_run_schedules.hasAccess`. Inventory/status operations use a narrow maintenance session; source computation always switches to the saved owner. Every recipient must resolve to the correct identity and pass source READ, Analyze and FULL access for all selected/filter fields. Different recipients receive a fresh computation under their own record locks. Skipped ids appear in `last_status`.
Email includes escaped HTML headline metrics and a result table (first 20 rows for pivots), with the requested CSV/XLSX attachment via core `SendMessageAction`. `HTML_SUMMARY` has no attachment. Missing messaging configuration and delivery errors persist an explicit failure and raise the declared **`data.analysis.schedule.failed`** alert. Core rejects underscores in alert names, so the spec's `data_analysis.schedule_failed` spelling cannot be registered. With no alert sink, core logs the signal and the status remains visible.
The installed core's stock `EMAIL` provider silently ignores `SendMessageInput.attachmentList`. CSV/XLSX schedules therefore reject that provider with an explicit status/alert; configure an attachment-capable core messaging provider. A test provider verifies the complete message and attachment contract. This qbit does not alter core's SMTP transport.
**Send now** uses the standard streamed ETL Review/Result record action. Reviews do not deliver; execution reauthorizes and reports actual outcomes. Disabled schedules can be sent manually. A runtime serializes scheduled and manual attempts, but there is no cross-node transaction or exactly-once email guarantee; run the scheduler on one node. New configurations compute their next occurrence; failed attempts advance to the next occurrence instead of retrying every five minutes.
## SQL Workbench and dashboard widgets
`POST /tables/{table}/sql` accepts `{kind,request}` and returns `{available,sql,note}`. It requires an installed SQL Workbench app/table and the configured app access permission (default `sql_workbench.hasAccess`), plus source permissions. Memory/non-RDBMS backends report unavailable. The engine executes the request as the viewer and reads the actual main aggregate's core `QueryStat`; grouped dimensions and the widest metric batch identify the main statement. Bound `?` placeholders remain, and the note states that QQQ applies record security at runtime and the SQL is for reading.
Core exposes `QueryStat` on the backend but not the backend reference on `AggregateAction`. A narrow read-only reflective bridge accesses that reference after normal core execution. Query-stat capability is enabled only on the request's cloned table metadata; no global logger or statistics-manager settings change. If core changes that private field or does not provide SQL, the endpoint reports unavailable rather than fabricating a statement.
The Analyses home uses standard `multiStatistics` and `table` widgets: mine/shared/starred counts, enabled/firing watches, my runs in the last seven days, starred analysis links, firing watches and recent run links. All counts and rows use the viewer's session. Tables have at most four columns and use Next's responsive table/card renderer, with explicit empty/permission states.
`SavedAnalysisWidget.widget()` supplies the **`DATA_ANALYSIS_SAVED`** metadata. Clone/rename it for another dashboard and set `.withDefaultValue("savedId", id)`. Its standard chart renderer re-runs as the viewer, shows formatted headline metrics, projects the semantic series/pivot/crosstab into a standard chart, and links back to the saved record. It never displays an owner's cached result. The standard chart is a dashboard summary; the full analysis UI remains responsible for specialized box/heatmap presentations.
## Adopting in another app
1. Add a backend, install the producer and run the usual `QInstanceValidator`.
2. For PostgreSQL, obtain `DataAnalysisSchema.postgresScript(config)` and apply it through your application's migration system. It derives fields and unique indexes from table metadata. It does not execute DDL or upgrade existing schemas.
3. Map user-id security keys and permissions in the authentication adapter. Configure core audit persistence if field audit history is needed.
4. Review per-table include/exclude sets, limits and minimum group sizes before enabling analysis. Integrate Saved Analyses and Watches with host ESB/Quick Search when those optional host features are installed; this scaffold does not add those dependencies.
5. Use the Analyses home as the owner of analysis data. Other dashboards can place a configured DATA_ANALYSIS_SAVED widget linked to its saved record.
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 -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 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
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
[Apache License 2.0](LICENSE). Copyright 2026 Kingsrook, LLC. See [NOTICE](NOTICE).
+4
View File
@@ -0,0 +1,4 @@
# Security Policy
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
* Report a vulnerability privately through this repository's **Security** tab ("Report a vulnerability").
+8
View File
@@ -0,0 +1,8 @@
# Upgrading
## Unreleased
No schema change and nothing required of hosts.
- New table action **Create rows for analyzable tables** on Analysis Settings. Run it once if you want the effective settings visible and editable as rows. It changes no behavior, is insert-only, and only adds rows for tables that have none. After a row exists it wins over later changes to the code defaults for that table.
- New **Setup** widget first on the Analyses home, and table help text. Hosts that override help on a table keep their own.
+246
View File
@@ -0,0 +1,246 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. 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.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
+19
View File
@@ -0,0 +1,19 @@
/*
* 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.
*/
-28
View File
@@ -1,28 +0,0 @@
# Getting Started
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
1. Choose **Use this template** on GitHub, then clone the repository you created.
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
5. Build the generated project:
```bash
mvn clean verify
```
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
## Register with a host application
The following uses the original example names; substitute the names chosen above:
```java
new ExampleExtensionQBitProducer()
.withConfig(new ExampleExtensionQBitConfig()
.withTargetTableName("order"))
.produce(qInstance, "my-extension");
```
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
-16
View File
@@ -1,16 +0,0 @@
# Extension Patterns
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
```java
QTableMetaData table = qInstance.getTable(tableName);
if(table == null)
{
throw new QException("Target table not found: " + tableName);
}
new ExampleTableCustomizer().customize(table);
```
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
+18
View File
@@ -0,0 +1,18 @@
# Data Analysis design
Approved specification: [QQQ docs/DATA-ANALYSIS.md](https://github.com/QRun-IO/qqq/blob/develop/docs/DATA-ANALYSIS.md), introduced in [PR #1137](https://github.com/QRun-IO/qqq/pull/1137).
Data Analysis will provide profiles, pivots, comparisons, relationships and anomaly watches on existing QQQ data. It will push supported computation to the backend, use bounded streamed fallbacks, enforce the viewer’s permissions and privacy limits, and store configuration, saved definitions, schedules and run history in ordinary visible tables. Backend phases b–f are implemented here: typed analysis engines, saved/shared/starred definitions, schedules, exports, SQL handoff and standard widgets with session-scoped data. Specialized analysis frontend work lives in Next. README documents the required shared-read migration and the installed core transport limitations.
## Phases (spec §11)
| Phase | Repo(s) | Deliverable |
|---|---|---|
| spec | qqq | this document |
| repo | qbit-data-analysis | repo standard, scaffold, producer, config, tables, permissions |
| a | qqq | §3: operators, buckets, capabilities, streamed fallback, sampling, feature-permission hook; memory + RDBMS (Postgres via Testcontainers, H2) |
| b | qbit, Next, (qbit-authentik) | §4 profiles + drill-through + runs/cache + §8 endpoints for them; column-header action and profile panel; authentik catalog FEATURE action |
| c | qbit, Next | §5 pivot explorer |
| d | qbit, Next | §6 compare, shift, correlation, crosstab |
| e | qbit | §7.6 watches and alerts |
| f | qbit, Next | §7 saved, stars, schedules, widgets, Analyses home, export |
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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
#
# http://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.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+224 -21
View File
@@ -1,36 +1,122 @@
<?xml version="1.0" encoding="UTF-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">
<modelVersion>4.0.0</modelVersion>
<!--
~ Copyright 2026 Kingsrook, LLC
~
~ 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
~
~ http://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.
-->
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-example-extension</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>QBit Example Extension</name>
<description>Example Extension QBit - rename this for your QBit</description>
<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.7</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.0.0</qqq.version>
<jacoco.version>0.8.15</jacoco.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-data-analysis</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit Data Analysis</name>
<description>QQQ QBit: statistical analysis and data dives on any table, filter or saved view (profiles, pivots, compare, anomalies, saved and scheduled analyses)</description>
<url>https://github.com/QRun-IO/qbit-data-analysis</url>
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-data-analysis.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-data-analysis.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-data-analysis</url>
<tag>HEAD</tag>
</scm>
<dependencies>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.4.240</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.9</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>${qqq.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -42,17 +128,134 @@
</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-compiler-plugin</artifactId>
<version>3.11.0</version>
<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-surefire-plugin</artifactId>
<version>3.1.2</version>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>10.12.6</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<headerLocation>checkstyle/license.txt</headerLocation>
<encoding>UTF-8</encoding>
<consoleOutput>true</consoleOutput>
<failsOnError>false</failsOnError>
<failOnViolation>true</failOnViolation>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>false</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
<profiles>
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
</profiles>
</project>
@@ -0,0 +1,506 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import com.kingsrook.qbits.dataanalysis.engine.CoreTierFieldAccessResolver;
import com.kingsrook.qbits.dataanalysis.engine.FieldAnalysisAccessResolver;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
/*******************************************************************************
** Code configuration for Data Analysis; null table overrides inherit defaults.
*******************************************************************************/
public class DataAnalysisQBitConfig implements QBitConfig
{
private Supplier<FieldAnalysisAccessResolver> fieldAnalysisAccessResolver = CoreTierFieldAccessResolver::defaultResolver;
private transient java.util.function.Function<String, com.kingsrook.qqq.backend.core.model.session.QSession> ownerSessionSupplier;
private String backendName;
private String schedulerName;
private String appName = "dataAnalysis";
private String appLabel = "Analyses";
private String parentAppName;
private Integer defaultMaxRows = 1_000_000;
private Integer defaultSampleThresholdRows = 1_000_000;
private Integer defaultSampleRows = 100_000;
private Integer defaultMinGroupSize = 0;
private Integer defaultCacheTtlMinutes = 60;
private Set<String> includeTables = new LinkedHashSet<>();
private Set<String> excludeTables = new LinkedHashSet<>();
private Map<String, TableAnalysisDefaults> tableDefaults = new LinkedHashMap<>();
public static final String TABLE_NAME_PREFIX = "data_analysis_";
public static final String PERMISSION_BASE_NAME = "data_analysis";
/*******************************************************************************
** Validate the backend and numeric defaults, collecting all errors.
*******************************************************************************/
@Override
public void validate(QInstance instance, List<String> errors)
{
if(backendName == null || instance.getBackend(backendName) == null)
{
errors.add("backendName must name an existing backend");
}
if(defaultMaxRows == null || defaultMaxRows < 0)
{
errors.add("defaultMaxRows must be non-negative");
}
if(defaultSampleThresholdRows == null || defaultSampleThresholdRows < 0)
{
errors.add("defaultSampleThresholdRows must be non-negative");
}
if(defaultSampleRows == null || defaultSampleRows < 0)
{
errors.add("defaultSampleRows must be non-negative");
}
if(defaultMinGroupSize == null || defaultMinGroupSize < 0)
{
errors.add("defaultMinGroupSize must be non-negative");
}
if(defaultCacheTtlMinutes == null || defaultCacheTtlMinutes < 0)
{
errors.add("defaultCacheTtlMinutes must be non-negative");
}
if(tableDefaults == null)
{
errors.add("tableDefaults must not be null");
return;
}
for(Map.Entry<String, TableAnalysisDefaults> entry : tableDefaults.entrySet())
{
if(entry.getValue() == null)
{
errors.add("tableDefaults[" + entry.getKey() + "] must not be null");
}
else
{
entry.getValue().validate("tableDefaults[" + entry.getKey() + "].", errors);
}
}
}
/*******************************************************************************
** The backend connection used by the qbit tables.
*******************************************************************************/
@Override
public String getDefaultBackendNameForTables()
{
return (backendName);
}
/*******************************************************************************
** Getter for backendName.
*******************************************************************************/
public String getBackendName()
{
return (backendName);
}
/*******************************************************************************
** Fluent setter for backendName.
*******************************************************************************/
public DataAnalysisQBitConfig withBackendName(String backendName)
{
this.backendName = backendName;
return (this);
}
/*******************************************************************************
** Getter for schedulerName.
*******************************************************************************/
public String getSchedulerName()
{
return (schedulerName);
}
/*******************************************************************************
** Fluent setter for schedulerName.
*******************************************************************************/
public DataAnalysisQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Getter for appName.
*******************************************************************************/
public String getAppName()
{
return (appName);
}
/*******************************************************************************
** Fluent setter for appName.
*******************************************************************************/
public DataAnalysisQBitConfig withAppName(String appName)
{
this.appName = appName;
return (this);
}
/*******************************************************************************
** Getter for appLabel.
*******************************************************************************/
public String getAppLabel()
{
return (appLabel);
}
/*******************************************************************************
** Fluent setter for appLabel.
*******************************************************************************/
public DataAnalysisQBitConfig withAppLabel(String appLabel)
{
this.appLabel = appLabel;
return (this);
}
/*******************************************************************************
** Getter for parentAppName.
*******************************************************************************/
public String getParentAppName()
{
return (parentAppName);
}
/*******************************************************************************
** Fluent setter for parentAppName.
*******************************************************************************/
public DataAnalysisQBitConfig withParentAppName(String parentAppName)
{
this.parentAppName = parentAppName;
return (this);
}
/*******************************************************************************
** Getter for defaultMaxRows.
*******************************************************************************/
public Integer getDefaultMaxRows()
{
return (defaultMaxRows);
}
/*******************************************************************************
** Fluent setter for defaultMaxRows.
*******************************************************************************/
public DataAnalysisQBitConfig withDefaultMaxRows(Integer defaultMaxRows)
{
this.defaultMaxRows = defaultMaxRows;
return (this);
}
/*******************************************************************************
** Getter for defaultSampleThresholdRows.
*******************************************************************************/
public Integer getDefaultSampleThresholdRows()
{
return (defaultSampleThresholdRows);
}
/*******************************************************************************
** Fluent setter for defaultSampleThresholdRows.
*******************************************************************************/
public DataAnalysisQBitConfig withDefaultSampleThresholdRows(Integer defaultSampleThresholdRows)
{
this.defaultSampleThresholdRows = defaultSampleThresholdRows;
return (this);
}
/*******************************************************************************
** Getter for defaultSampleRows.
*******************************************************************************/
public Integer getDefaultSampleRows()
{
return (defaultSampleRows);
}
/*******************************************************************************
** Fluent setter for defaultSampleRows.
*******************************************************************************/
public DataAnalysisQBitConfig withDefaultSampleRows(Integer defaultSampleRows)
{
this.defaultSampleRows = defaultSampleRows;
return (this);
}
/*******************************************************************************
** Getter for defaultMinGroupSize.
*******************************************************************************/
public Integer getDefaultMinGroupSize()
{
return (defaultMinGroupSize);
}
/*******************************************************************************
** Fluent setter for defaultMinGroupSize.
*******************************************************************************/
public DataAnalysisQBitConfig withDefaultMinGroupSize(Integer defaultMinGroupSize)
{
this.defaultMinGroupSize = defaultMinGroupSize;
return (this);
}
/*******************************************************************************
** Getter for defaultCacheTtlMinutes.
*******************************************************************************/
public Integer getDefaultCacheTtlMinutes()
{
return (defaultCacheTtlMinutes);
}
/*******************************************************************************
** Fluent setter for defaultCacheTtlMinutes.
*******************************************************************************/
public DataAnalysisQBitConfig withDefaultCacheTtlMinutes(Integer defaultCacheTtlMinutes)
{
this.defaultCacheTtlMinutes = defaultCacheTtlMinutes;
return (this);
}
/*******************************************************************************
** Getter for includeTables.
*******************************************************************************/
public Set<String> getIncludeTables()
{
return (includeTables);
}
/*******************************************************************************
** Fluent setter for includeTables.
*******************************************************************************/
public DataAnalysisQBitConfig withIncludeTables(Set<String> includeTables)
{
this.includeTables = includeTables;
return (this);
}
/*******************************************************************************
** Getter for excludeTables.
*******************************************************************************/
public Set<String> getExcludeTables()
{
return (excludeTables);
}
/*******************************************************************************
** Fluent setter for excludeTables.
*******************************************************************************/
public DataAnalysisQBitConfig withExcludeTables(Set<String> excludeTables)
{
this.excludeTables = excludeTables;
return (this);
}
/*******************************************************************************
** Getter for tableDefaults.
*******************************************************************************/
public Map<String, TableAnalysisDefaults> getTableDefaults()
{
return (tableDefaults);
}
/*******************************************************************************
** Fluent setter for tableDefaults.
*******************************************************************************/
public DataAnalysisQBitConfig withTableDefaults(Map<String, TableAnalysisDefaults> tableDefaults)
{
this.tableDefaults = tableDefaults;
return (this);
}
/*******************************************************************************
** Host override for session-aware field analysis access.
*******************************************************************************/
public Supplier<FieldAnalysisAccessResolver> getFieldAnalysisAccessResolver()
{
return (fieldAnalysisAccessResolver);
}
/*******************************************************************************
** Configure an instance supplier; each service resolves one policy instance.
*******************************************************************************/
public DataAnalysisQBitConfig withFieldAnalysisAccessResolver(Supplier<FieldAnalysisAccessResolver> resolver)
{
this.fieldAnalysisAccessResolver = java.util.Objects.requireNonNull(resolver);
return (this);
}
/*******************************************************************************
** Optional host adapter for scheduled owner permissions and record-lock keys.
*******************************************************************************/
public java.util.function.Function<String, com.kingsrook.qqq.backend.core.model.session.QSession> getOwnerSessionSupplier()
{
return (ownerSessionSupplier);
}
/*******************************************************************************
** Without an adapter, use the host authentication module's automated session.
*******************************************************************************/
public DataAnalysisQBitConfig withOwnerSessionSupplier(java.util.function.Function<String, com.kingsrook.qqq.backend.core.model.session.QSession> supplier)
{
ownerSessionSupplier = supplier;
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;
/*******************************************************************************
** Core messaging provider used for scheduled delivery.
*******************************************************************************/
public String getMessagingProviderName()
{
return (messagingProviderName);
}
/*******************************************************************************
** Select the host's configured SMTP or SES provider.
*******************************************************************************/
public DataAnalysisQBitConfig withMessagingProviderName(String value)
{
messagingProviderName = value;
return (this);
}
/*******************************************************************************
** Verified sender address supplied by the host.
*******************************************************************************/
public String getFromAddress()
{
return (fromAddress);
}
/*******************************************************************************
** Set the scheduled email sender.
*******************************************************************************/
public DataAnalysisQBitConfig withFromAddress(String value)
{
fromAddress = value;
return (this);
}
/*******************************************************************************
** Resolve a user id to its current email; never treat recipients as addresses.
*******************************************************************************/
public java.util.function.Function<String, String> getRecipientAddressSupplier()
{
return (recipientAddressSupplier);
}
/*******************************************************************************
** Bind the host directory's user-to-email lookup.
*******************************************************************************/
public DataAnalysisQBitConfig withRecipientAddressSupplier(java.util.function.Function<String, String> value)
{
recipientAddressSupplier = value;
return (this);
}
}
@@ -0,0 +1,222 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis;
import java.io.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;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider;
/*******************************************************************************
** Entry point for the Data Analysis qbit; scans only its metadata package.
*******************************************************************************/
public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalysisQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-data-analysis";
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.
*******************************************************************************/
public DataAnalysisQBitProducer withQBitConfig(DataAnalysisQBitConfig config)
{
qBitConfig = config;
return (this);
}
/*******************************************************************************
** Configuration for component producers.
*******************************************************************************/
@Override
public DataAnalysisQBitConfig getQBitConfig()
{
return (qBitConfig);
}
/*******************************************************************************
** Fluent namespace setter.
*******************************************************************************/
public DataAnalysisQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
** Qbit namespace.
*******************************************************************************/
@Override
public String getNamespace()
{
return (namespace);
}
/*******************************************************************************
** Qbit identity.
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
}
/*******************************************************************************
** Unconfigured producers discovered by host scanning are disabled.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Helpers, renderers and the top-level producer are never component producers.
*******************************************************************************/
@Override
public String getPackageNameForFindingMetaDataProducers()
{
return ("com.kingsrook.qbits.dataanalysis.metadata");
}
/*******************************************************************************
** Install metadata and attach the app to its optional parent, like reference qbits.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance) throws QException
{
qBitConfig.validate(instance);
if(instance.getPossibleValueSource(TablesPossibleValueSourceMetaDataProvider.NAME) == null)
{
instance.addPossibleValueSource(TablesPossibleValueSourceMetaDataProvider.defineTablesPossibleValueSource(instance));
}
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(instance);
output.addSelfToInstance(instance);
new DataAnalysisRuntime(qBitConfig).addSelfToInstance(instance);
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(instance).withAlertType(
new com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData().withName(com.kingsrook.qbits.dataanalysis.watch.WatchEvaluator.ALERT_TYPE)
.withLabel("Data anomaly").withSourceName(ARTIFACT_ID).withGroupName("data-analysis")
.withDefaultSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.WARNING)
.withResolveMode(com.kingsrook.qqq.backend.core.alerts.ResolveMode.PRODUCER)
.withSubjectTableName(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.WATCH)
.withVisibilityPermission("data_analysis.watch.read"));
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(instance).withAlertType(
new com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData().withName(com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner.ALERT_TYPE)
.withLabel("Analysis schedule failed").withSourceName(ARTIFACT_ID).withGroupName("data-analysis")
.withDefaultSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR)
.withResolveMode(com.kingsrook.qqq.backend.core.alerts.ResolveMode.PRODUCER)
.withSubjectTableName(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.SCHEDULE)
.withVisibilityPermission("data_analysis.saved.read"));
var included = java.util.Set.copyOf(qBitConfig.getIncludeTables());
var excluded = java.util.Set.copyOf(qBitConfig.getExcludeTables());
TableFeaturePermissionsMetaData.ofOrWithNew(instance).withPermission(new TableFeaturePermission()
.withSuffix("analyze").withLabel("Analyze").withImpliedBy(null)
.withAppliesTo((qInstance, table) -> !table.getName().startsWith(DataAnalysisQBitConfig.TABLE_NAME_PREFIX)
&& (included.isEmpty() || included.contains(table.getName())) && !excluded.contains(table.getName())));
try
{
Class.forName("io.javalin.Javalin");
Class.forName("com.kingsrook.qqq.middleware.javalin.QJavalinImplementation");
DataAnalysisApi.register(instance);
}
catch(ClassNotFoundException expected)
{
// The engine and metadata also work without the optional middleware.
}
if(qBitConfig.getParentAppName() != null && instance.getApp(qBitConfig.getParentAppName()) != null)
{
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());
}
}
@@ -0,0 +1,103 @@
/*
* 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 com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
/*******************************************************************************
** Instance-scoped service holder for UI API endpoints and host integrations.
*******************************************************************************/
public class DataAnalysisRuntime implements QSupplementalInstanceMetaData
{
private final transient DataAnalysisService service;
private final transient com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner schedules;
private final transient com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses saved;
private final transient com.kingsrook.qbits.dataanalysis.watch.WatchEvaluator watches;
/*******************************************************************************
** Create a runtime for one configured instance.
*******************************************************************************/
public DataAnalysisRuntime(DataAnalysisQBitConfig config)
{
service = new DataAnalysisService(config);
schedules = new com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner(config, service);
saved = new com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses(service);
watches = new com.kingsrook.qbits.dataanalysis.watch.WatchEvaluator(config, service);
}
/*******************************************************************************
** Stable supplemental metadata name.
*******************************************************************************/
@Override
public String getName()
{
return ("dataAnalysisRuntime");
}
/*******************************************************************************
** Runtime objects never appear in metadata JSON.
*******************************************************************************/
@JsonIgnore
public DataAnalysisService getService()
{
return (service);
}
/*******************************************************************************
** Locate the service belonging to the current QInstance.
*******************************************************************************/
public static DataAnalysisService service()
{
return (((DataAnalysisRuntime) QContext.getQInstance().getSupplementalMetaData("dataAnalysisRuntime")).getService());
}
/*******************************************************************************
** Shared evaluator serializes manual and scheduled watch transitions.
*******************************************************************************/
public static com.kingsrook.qbits.dataanalysis.watch.WatchEvaluator watches()
{
return (((DataAnalysisRuntime) QContext.getQInstance().getSupplementalMetaData("dataAnalysisRuntime")).watches);
}
/*******************************************************************************
** Saved operations share the instance's engine and serialize star toggles.
*******************************************************************************/
public static com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses saved()
{
return (((DataAnalysisRuntime) QContext.getQInstance().getSupplementalMetaData("dataAnalysisRuntime")).saved);
}
/*******************************************************************************
** Shared schedule runner for manual and automatic delivery.
*******************************************************************************/
public static com.kingsrook.qbits.dataanalysis.schedule.ScheduleRunner schedules()
{
return (((DataAnalysisRuntime) QContext.getQInstance().getSupplementalMetaData("dataAnalysisRuntime")).schedules);
}
}
@@ -0,0 +1,101 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** PostgreSQL DDL generated from the same tables and unique keys used by QQQ.
*******************************************************************************/
public final class DataAnalysisSchema
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisSchema()
{
}
/*******************************************************************************
** Idempotent table and unique index creation; hosts own migration execution.
*******************************************************************************/
public static String postgresScript(DataAnalysisQBitConfig config)
{
List<String> statements = new ArrayList<>();
for(QTableMetaData table : DataAnalysisTables.tables(config))
{
List<String> columns = new ArrayList<>();
for(QFieldMetaData field : table.getFields().values())
{
columns.add(" " + field.getBackendName() + " " + ("id".equals(field.getName())
? "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : sqlType(field) + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : "")));
}
if(DataAnalysisTables.SAVED.equals(table.getName()))
{
///////////////////////////////////////////////////////////////////////////////
// a null read-owner projection means "shared": make the database refuse it //
// on a private row, so a row written outside QQQ fails closed, not open //
///////////////////////////////////////////////////////////////////////////////
columns.add(" CONSTRAINT ck_data_analysis_saved_read_owner CHECK (shared 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)
{
int index = 0;
for(UniqueKey key : table.getUniqueKeys())
{
index++;
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_" + table.getName() + "_" + index + " ON " + table.getName()
+ " (" + String.join(", ", key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList()) + ")");
}
}
}
return (String.join(";\n", statements) + ";\n");
}
/*******************************************************************************
** PostgreSQL types for the table metadata.
*******************************************************************************/
private static String sqlType(QFieldMetaData field)
{
return (switch(field.getType())
{
case INTEGER -> "INTEGER";
case LONG -> "BIGINT";
case DECIMAL -> "NUMERIC";
case BOOLEAN -> "BOOLEAN";
case DATE_TIME -> "TIMESTAMP WITHOUT TIME ZONE";
case TEXT -> "TEXT";
default -> "VARCHAR(" + field.getMaxLength() + ")";
});
}
}
@@ -0,0 +1,379 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executor;
import com.kingsrook.qbits.dataanalysis.engine.AnalysisBackend;
import com.kingsrook.qbits.dataanalysis.engine.AnalysisDrillGuard;
import com.kingsrook.qbits.dataanalysis.engine.AnalysisLabels;
import com.kingsrook.qbits.dataanalysis.engine.AnalysisSecurity;
import com.kingsrook.qbits.dataanalysis.engine.ColumnProfiler;
import com.kingsrook.qbits.dataanalysis.engine.ComparisonExplorer;
import com.kingsrook.qbits.dataanalysis.engine.CoreAnalysisBackend;
import com.kingsrook.qbits.dataanalysis.engine.DistinctValues;
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
import com.kingsrook.qbits.dataanalysis.engine.DuplicateFinder;
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfigResolver;
import com.kingsrook.qbits.dataanalysis.engine.FieldAnalysisAccessResolver;
import com.kingsrook.qbits.dataanalysis.engine.PivotExplorer;
import com.kingsrook.qbits.dataanalysis.engine.RelationshipExplorer;
import com.kingsrook.qbits.dataanalysis.engine.ShiftExplorer;
import com.kingsrook.qbits.dataanalysis.engine.StandInAnalysisBackend;
import com.kingsrook.qbits.dataanalysis.engine.TableProfiler;
import com.kingsrook.qbits.dataanalysis.result.AnalysisRequest;
import com.kingsrook.qbits.dataanalysis.result.Capabilities;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.CompareRequest;
import com.kingsrook.qbits.dataanalysis.result.CompareResult;
import com.kingsrook.qbits.dataanalysis.result.CorrelationRequest;
import com.kingsrook.qbits.dataanalysis.result.CorrelationResult;
import com.kingsrook.qbits.dataanalysis.result.CrosstabRequest;
import com.kingsrook.qbits.dataanalysis.result.CrosstabResult;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesPage;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesRequest;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesRequest;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesResult;
import com.kingsrook.qbits.dataanalysis.result.PivotRequest;
import com.kingsrook.qbits.dataanalysis.result.PivotResult;
import com.kingsrook.qbits.dataanalysis.result.RunStatus;
import com.kingsrook.qbits.dataanalysis.result.ScatterRequest;
import com.kingsrook.qbits.dataanalysis.result.ScatterResult;
import com.kingsrook.qbits.dataanalysis.result.ShiftRequest;
import com.kingsrook.qbits.dataanalysis.result.ShiftResult;
import com.kingsrook.qbits.dataanalysis.result.TableProfile;
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
import com.kingsrook.qbits.dataanalysis.run.AnalysisRunService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
/*******************************************************************************
** Single host facade for analysis; all entry points use the calling QContext.
*******************************************************************************/
public class DataAnalysisService
{
private final EffectiveConfigResolver configs;
private final AnalysisSecurity security;
private final FieldAnalysisAccessResolver fieldAccess;
private final AnalysisRunService runs;
/*******************************************************************************
** Default asynchronous jobs use the common executor with captured context.
*******************************************************************************/
public DataAnalysisService(DataAnalysisQBitConfig config)
{
this(config, java.util.concurrent.ForkJoinPool.commonPool());
}
/*******************************************************************************
** Hosts may supply their managed executor.
*******************************************************************************/
public DataAnalysisService(DataAnalysisQBitConfig config, Executor executor)
{
configs = new EffectiveConfigResolver(config);
fieldAccess = config.getFieldAnalysisAccessResolver().get();
security = new AnalysisSecurity(configs, fieldAccess);
runs = new AnalysisRunService(this, executor);
}
/*******************************************************************************
** Expose only readable, configured fields and detached effective settings.
*******************************************************************************/
public Capabilities capabilities(String table) throws QException
{
return (new Capabilities().withTableName(table).withFields(security.analyzableFields(table).stream().map(QFieldMetaData::getName).toList())
.withConfig(configs.resolve(table)).withBackend(CoreAnalysisBackend.capabilities(table).isEmpty() ? "Streamed analysis" : "Core aggregate pushdown"));
}
/*******************************************************************************
** Estimate above the threshold; otherwise reuse or persist an exact run.
*******************************************************************************/
public ColumnProfile columnProfile(ColumnProfileRequest request) throws QException
{
validate(request);
return (runs.columnProfile(request));
}
/*******************************************************************************
** Table profiles share the exact-run cache lifecycle.
*******************************************************************************/
public TableProfile tableProfile(TableProfileRequest request) throws QException
{
validate(request);
return (runs.tableProfile(request));
}
/*******************************************************************************
** Page guarded distinct values as the current user.
*******************************************************************************/
public DistinctValuesPage distinctValues(DistinctValuesRequest request) throws QException
{
validate(request);
return (protect(new DistinctValues(backend(request.getTableName()), fieldAccess).page(request, security.field(request.getTableName(), request.getFieldName()), configs.resolve(request.getTableName())), request));
}
/*******************************************************************************
** Find guarded duplicate groups using only authorized fields.
*******************************************************************************/
public DuplicatesResult duplicates(DuplicatesRequest request) throws QException
{
validate(request);
return (protect(new DuplicateFinder(backend(request.getTableName())).find(request, configs.resolve(request.getTableName())), request));
}
/*******************************************************************************
** Start either supported exact profile kind and return the persistent run id.
*******************************************************************************/
public String startExactRun(AnalysisRequest request) throws QException
{
validate(request);
return (runs.startExactRun(request));
}
/*******************************************************************************
** Owner-locked status plus a typed completed result.
*******************************************************************************/
public RunStatus getRun(String id) throws QException
{
return (runs.getRun(id));
}
/*******************************************************************************
** Settings editors can force an immediate runtime-policy refresh.
*******************************************************************************/
public EffectiveConfigResolver getConfigResolver()
{
return (configs);
}
/*******************************************************************************
** Select core pushdown when the backend advertises analysis capabilities.
*******************************************************************************/
public AnalysisBackend backend(String table) throws QException
{
return (CoreAnalysisBackend.capabilities(table).isEmpty() ? new StandInAnalysisBackend(configs.resolve(table)) : new CoreAnalysisBackend(configs.resolve(table)));
}
/*******************************************************************************
** Validate every entry point before counting source data or consulting cache.
*******************************************************************************/
public void validate(AnalysisRequest request) throws QException
{
if(request == null || request.getTableName() == null)
{
throw new QException("An analysis table is required.");
}
security.table(request.getTableName());
if(request instanceof ColumnProfileRequest column)
{
security.field(request.getTableName(), column.getFieldName());
}
else if(request instanceof DistinctValuesRequest distinct)
{
security.field(request.getTableName(), distinct.getFieldName());
}
else if(request instanceof DuplicatesRequest duplicates)
{
if(duplicates.getFields() == null || duplicates.getFields().isEmpty())
{
throw new QException("Choose at least one duplicate field.");
}
for(String field : duplicates.getFields())
{
security.field(request.getTableName(), field);
}
}
else if(request instanceof TableProfileRequest table && table.getFields() != null)
{
for(String field : table.getFields())
{
security.field(request.getTableName(), field);
}
}
}
/*******************************************************************************
** The run layer supplies the scope; privacy is applied by the profiler.
*******************************************************************************/
public ColumnProfile compute(ColumnProfileRequest request, AnalysisBackend backend, AnalysisBackend.Scope scope) throws QException
{
validate(request);
return (protect(new ColumnProfiler(backend, fieldAccess).profile(request, security.field(request.getTableName(), request.getFieldName()), scope, configs.resolve(request.getTableName())), request));
}
/*******************************************************************************
** Resolve requested fields against personalized metadata on every execution.
*******************************************************************************/
public TableProfile compute(TableProfileRequest request, AnalysisBackend backend, AnalysisBackend.Scope scope) throws QException
{
validate(request);
List<QFieldMetaData> fields = security.analyzableFields(request.getTableName()).stream()
.filter(f -> request.getFields() == null || request.getFields().contains(f.getName())).toList();
return (protect(new TableProfiler(backend).profile(request, fields, scope), request));
}
/*******************************************************************************
** Field policy identity is re-evaluated before cache reads and writes.
*******************************************************************************/
public String fieldAccessFingerprint(String table) throws QException
{
return (security.accessFingerprint(table));
}
/*******************************************************************************
** Execute a pivot as the current user through core aggregates.
*******************************************************************************/
public PivotResult pivot(PivotRequest request) throws QException
{
validate(request);
return (new PivotExplorer(security, configs.resolve(request.getTableName())).execute(request));
}
/*******************************************************************************
** Generated primary-key drills also obey the configured field-access seam.
*******************************************************************************/
private <T> T protect(T result, AnalysisRequest request) throws QException
{
new AnalysisDrillGuard(Set.copyOf(capabilities(request.getTableName()).getFields()),
DrillFilters.restoreTypes(request.getTableName(), request.getFilter())).apply(result);
return (result);
}
/*******************************************************************************
** Include resolved tiers of related label fields in cache identity.
*******************************************************************************/
public Map<String, Object> fieldAccessPolicy(String table) throws QException
{
return (AnalysisLabels.policy(table, fieldAccess));
}
/*******************************************************************************
** Execute compare under the current user's FULL-field and record policy.
*******************************************************************************/
public CompareResult compare(CompareRequest request) throws QException
{
validate(request);
return (new ComparisonExplorer(security, configs.resolve(request.getTableName()), request.getTableName()).compare(request));
}
/*******************************************************************************
** Execute correlation under the current user's FULL-field and record policy.
*******************************************************************************/
public CorrelationResult correlation(CorrelationRequest request) throws QException
{
validate(request);
return (new RelationshipExplorer(security, configs.resolve(request.getTableName()), request.getTableName()).correlation(request));
}
/*******************************************************************************
** Execute crosstab under the current user's FULL-field and record policy.
*******************************************************************************/
public CrosstabResult crosstab(CrosstabRequest request) throws QException
{
validate(request);
return (new RelationshipExplorer(security, configs.resolve(request.getTableName()), request.getTableName()).crosstab(request));
}
/*******************************************************************************
** Execute scatter under the current user's FULL-field and record policy.
*******************************************************************************/
public ScatterResult scatter(ScatterRequest request) throws QException
{
validate(request);
return (new RelationshipExplorer(security, configs.resolve(request.getTableName()), request.getTableName()).scatter(request));
}
/*******************************************************************************
** Execute shift under the current user's FULL-field and record policy.
*******************************************************************************/
public ShiftResult shift(ShiftRequest request) throws QException
{
validate(request);
return (new ShiftExplorer(security, configs.resolve(request.getTableName()), request.getTableName()).shift(request));
}
/*******************************************************************************
** Watch aggregates use the same current-user table and FULL-field policy.
*******************************************************************************/
public com.kingsrook.qbits.dataanalysis.watch.WatchPreview watchPreview(com.kingsrook.qqq.backend.core.model.data.QRecord row, java.time.Instant now) throws QException
{
var resolved = com.kingsrook.qbits.dataanalysis.watch.WatchRecords.resolve(row);
String table = resolved.getValueString("table_name");
security.table(table);
var definition = com.kingsrook.qbits.dataanalysis.watch.WatchDefinition.parse(resolved);
return (new com.kingsrook.qbits.dataanalysis.watch.WatchSeries(security, configs.resolve(table), table)
.preview(definition, com.kingsrook.qbits.dataanalysis.watch.WatchRecords.filter(resolved), now));
}
/*******************************************************************************
** Saved definitions drop selected fields using the same FULL access resolver.
*******************************************************************************/
public boolean isAvailableField(String table, String field)
{
try
{
if(field.contains("."))
{
String[] parts = field.split("\\.", 2);
security.joinedField(parts[0], parts[1]);
}
else
{
security.field(table, field);
}
return (true);
}
catch(QException e)
{
return (false);
}
}
}
@@ -0,0 +1,239 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis;
import java.io.Serializable;
import java.util.List;
import java.util.Set;
/*******************************************************************************
** Code configuration for Data Analysis; null table overrides inherit defaults.
*******************************************************************************/
public class TableAnalysisDefaults implements Serializable
{
private Boolean enabled;
private Integer maxRows;
private Integer sampleThresholdRows;
private Integer sampleRows;
private Integer minGroupSize;
private Integer cacheTtlMinutes;
private Set<String> analyzableFields;
private Set<String> excludedFields;
/*******************************************************************************
** Reject negative overrides while allowing null to inherit.
*******************************************************************************/
public void validate(String prefix, List<String> errors)
{
if(maxRows != null && maxRows < 0)
{
errors.add(prefix + "maxRows must be non-negative");
}
if(sampleThresholdRows != null && sampleThresholdRows < 0)
{
errors.add(prefix + "sampleThresholdRows must be non-negative");
}
if(sampleRows != null && sampleRows < 0)
{
errors.add(prefix + "sampleRows must be non-negative");
}
if(minGroupSize != null && minGroupSize < 0)
{
errors.add(prefix + "minGroupSize must be non-negative");
}
if(cacheTtlMinutes != null && cacheTtlMinutes < 0)
{
errors.add(prefix + "cacheTtlMinutes must be non-negative");
}
}
/*******************************************************************************
** Getter for enabled.
*******************************************************************************/
public Boolean getEnabled()
{
return (enabled);
}
/*******************************************************************************
** Fluent setter for enabled.
*******************************************************************************/
public TableAnalysisDefaults withEnabled(Boolean enabled)
{
this.enabled = enabled;
return (this);
}
/*******************************************************************************
** Getter for maxRows.
*******************************************************************************/
public Integer getMaxRows()
{
return (maxRows);
}
/*******************************************************************************
** Fluent setter for maxRows.
*******************************************************************************/
public TableAnalysisDefaults withMaxRows(Integer maxRows)
{
this.maxRows = maxRows;
return (this);
}
/*******************************************************************************
** Getter for sampleThresholdRows.
*******************************************************************************/
public Integer getSampleThresholdRows()
{
return (sampleThresholdRows);
}
/*******************************************************************************
** Fluent setter for sampleThresholdRows.
*******************************************************************************/
public TableAnalysisDefaults withSampleThresholdRows(Integer sampleThresholdRows)
{
this.sampleThresholdRows = sampleThresholdRows;
return (this);
}
/*******************************************************************************
** Getter for sampleRows.
*******************************************************************************/
public Integer getSampleRows()
{
return (sampleRows);
}
/*******************************************************************************
** Fluent setter for sampleRows.
*******************************************************************************/
public TableAnalysisDefaults withSampleRows(Integer sampleRows)
{
this.sampleRows = sampleRows;
return (this);
}
/*******************************************************************************
** Getter for minGroupSize.
*******************************************************************************/
public Integer getMinGroupSize()
{
return (minGroupSize);
}
/*******************************************************************************
** Fluent setter for minGroupSize.
*******************************************************************************/
public TableAnalysisDefaults withMinGroupSize(Integer minGroupSize)
{
this.minGroupSize = minGroupSize;
return (this);
}
/*******************************************************************************
** Getter for cacheTtlMinutes.
*******************************************************************************/
public Integer getCacheTtlMinutes()
{
return (cacheTtlMinutes);
}
/*******************************************************************************
** Fluent setter for cacheTtlMinutes.
*******************************************************************************/
public TableAnalysisDefaults withCacheTtlMinutes(Integer cacheTtlMinutes)
{
this.cacheTtlMinutes = cacheTtlMinutes;
return (this);
}
/*******************************************************************************
** Getter for analyzableFields.
*******************************************************************************/
public Set<String> getAnalyzableFields()
{
return (analyzableFields);
}
/*******************************************************************************
** Fluent setter for analyzableFields.
*******************************************************************************/
public TableAnalysisDefaults withAnalyzableFields(Set<String> analyzableFields)
{
this.analyzableFields = analyzableFields;
return (this);
}
/*******************************************************************************
** Getter for excludedFields.
*******************************************************************************/
public Set<String> getExcludedFields()
{
return (excludedFields);
}
/*******************************************************************************
** Fluent setter for excludedFields.
*******************************************************************************/
public TableAnalysisDefaults withExcludedFields(Set<String> excludedFields)
{
this.excludedFields = excludedFields;
return (this);
}
}
@@ -0,0 +1,195 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.api;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.executors.AbstractMiddlewareExecutor;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareInput;
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareOutputInterface;
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.openapi.model.Content;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.RequestBody;
import com.kingsrook.qqq.openapi.model.Response;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
import io.javalin.http.Context;
/*******************************************************************************
** Binary endpoints extend AbstractEndpointSpec directly, preserving core sessions.
*******************************************************************************/
public class AnalysisFileSpec extends AbstractEndpointSpec<AnalysisFileSpec.Input, AnalysisFileSpec.Output, AnalysisFileSpec.Executor>
{
/*******************************************************************************
** POST an arbitrary supported typed request plus CSV/XLSX format.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(DataAnalysisApi.PREFIX + "/export").withHttpMethod(HttpMethod.POST)
.withShortSummary("Export an analysis result").withLongDescription("Execute a typed analysis as the signed-in viewer and download its result table as CSV or XLSX. Requires source READ/Analyze and FULL fields; uses core report formats."));
}
/*******************************************************************************
** Saved export has only id/format parameters; ad hoc export uses a JSON body.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of());
}
/*******************************************************************************
** Typed discriminated request plus file format.
*******************************************************************************/
@Override
public RequestBody defineRequestBody()
{
return (new RequestBody().withRequired(true).withContent(Map.of("application/json", new Content()
.withSchema(new Schema().withRef("#/components/schemas/DataAnalysisSavedRequestV1")))));
}
/*******************************************************************************
** Both file representations are declared, without JSON response wrapping.
*******************************************************************************/
@Override
public Map<Integer, Response> defineResponses()
{
Schema file = new Schema().withRef("#/components/schemas/DataAnalysisFileV1");
return (Map.of(200, new Response().withDescription("Analysis result file").withContent(new java.util.TreeMap<>(Map.of("text/csv", new Content().withSchema(file),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new Content().withSchema(file))))));
}
/*******************************************************************************
** Read transport data only; execution waits for middleware session setup.
*******************************************************************************/
@Override
public Input buildInput(Context context) throws Exception
{
Input input = new Input();
if(this instanceof Saved)
{
input.id = SavedApi.id(context.pathParam("id"));
input.format = context.queryParam("format");
}
else
{
if(context.contentType() == null || !context.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/json"))
{
throw new QBadRequestException("The request body must be JSON.");
}
input.body = SavedDefinition.object(context.body());
input.format = input.body.get("format") instanceof String value ? value : null;
}
AnalysisExport.format(input.format);
return (input);
}
/*******************************************************************************
** Actual file bytes, a stable filename and the matching MIME type.
*******************************************************************************/
@Override
public void handleOutput(Context context, Output output)
{
context.contentType(output.file.mimeType()).header("Content-Disposition", "attachment; filename=\"" + output.file.filename() + "\"")
.header("Cache-Control", "no-store").result(output.file.bytes());
}
/*******************************************************************************
** Saved GET variant uses the same file executor.
*******************************************************************************/
public static class Saved extends AnalysisFileSpec
{
/*******************************************************************************
** Core-visible saved definition is re-run as viewer before export.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(DataAnalysisApi.PREFIX + "/saved/{id}/export").withHttpMethod(HttpMethod.GET)
.withShortSummary("Export a saved analysis").withLongDescription("Re-run a visible saved analysis as the viewer, applying current source permissions and dropping unavailable fields, then download its result table as CSV or XLSX."));
}
/*******************************************************************************
** Required id and CSV/XLSX query format.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(new Parameter().withName("id").withIn(In.PATH).withRequired(true).withDescription("Visible saved analysis id.").withSchema(new Schema().withType(Type.INTEGER)),
new Parameter().withName("format").withIn(In.QUERY).withRequired(true).withDescription("Result file format.").withSchema(new Schema().withType(Type.STRING).withEnumValues(List.of("csv", "xlsx")))));
}
/*******************************************************************************
** GET has no request body.
*******************************************************************************/
@Override
public RequestBody defineRequestBody()
{
return (null);
}
}
/*******************************************************************************
** Input data only, inherited middleware fields carry the authenticated session.
*******************************************************************************/
public static class Input extends AbstractMiddlewareInput
{
private Integer id;
private String format;
private Map<String, Object> body;
}
/*******************************************************************************
** Binary output stays out of JSON serialization.
*******************************************************************************/
public static class Output implements AbstractMiddlewareOutputInterface
{
private AnalysisExport.File file;
}
/*******************************************************************************
** Invoked after the normal middleware session pipeline.
*******************************************************************************/
public static class Executor extends AbstractMiddlewareExecutor<Input, Output>
{
/*******************************************************************************
** No direct source access outside the existing authorized analysis engine.
*******************************************************************************/
@Override
public void execute(Input input, Output output) throws QException
{
var result = input.id == null ? SavedApi.definition(input.body, null).run(DataAnalysisRuntime.service(), true)
: DataAnalysisRuntime.saved().run(input.id);
output.file = AnalysisExport.file(result, input.format);
}
}
}
@@ -0,0 +1,794 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.api;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.DataAnalysisService;
import com.kingsrook.qbits.dataanalysis.engine.CoreAnalysisBackend;
import com.kingsrook.qbits.dataanalysis.result.AnalysisRequest;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.CompareRequest;
import com.kingsrook.qbits.dataanalysis.result.CorrelationRequest;
import com.kingsrook.qbits.dataanalysis.result.CrosstabRequest;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesRequest;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesRequest;
import com.kingsrook.qbits.dataanalysis.result.PivotRequest;
import com.kingsrook.qbits.dataanalysis.result.ScatterRequest;
import com.kingsrook.qbits.dataanalysis.result.ShiftRequest;
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Session-bound analysis operations registered only with optional Javalin present.
*******************************************************************************/
public class DataAnalysisApi
{
public static final String PREFIX = "/qbit/data-analysis";
/*******************************************************************************
** Publish the extension through the core spec registry.
*******************************************************************************/
public static void register(QInstance instance)
{
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/*******************************************************************************
** The same descriptor drives routes, OpenAPI and assistant tool discovery.
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
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)
.withSpec(CapabilitiesSpec.class).withSpec(ColumnProfileSpec.class).withSpec(DistinctValuesSpec.class)
.withSpec(CompareSpec.class).withSpec(ShiftSpec.class).withSpec(CorrelationSpec.class).withSpec(ScatterSpec.class).withSpec(CrosstabSpec.class)
.withSpec(WatchPreviewSpec.class).withSpec(PivotSpec.class).withSpec(TableProfileSpec.class).withSpec(DuplicatesSpec.class).withSpec(StartRunSpec.class).withSpec(GetRunSpec.class));
}
/*******************************************************************************
** Common schema catalog and path parameters for the analysis JSON specs.
*******************************************************************************/
public abstract static class ProfileSpec extends AbstractJsonEndpointSpec
{
private final String path;
private final HttpMethod method;
private final String summary;
private final String description;
private final String request;
private final String response;
/*******************************************************************************
** Bind immutable operation metadata.
*******************************************************************************/
protected ProfileSpec(String path, HttpMethod method, String summary, String description, String request, String response)
{
this.path = path;
this.method = method;
this.summary = summary;
this.description = description;
this.request = request;
this.response = response;
}
/*******************************************************************************
** Assistant-facing operation purpose.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(PREFIX + path).withHttpMethod(method).withShortSummary(summary).withLongDescription(description));
}
/*******************************************************************************
** Required path parameter, when present.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
String name = path.contains("{table}") ? "table" : path.contains("{runId}") ? "runId" : path.contains("{id}") ? "id" : null;
return (name == null ? List.of() : List.of(new Parameter().withName(name).withIn(In.PATH).withRequired(true)
.withDescription(name.equals("table") ? "QQQ table name to analyze." : "Owner-scoped analysis run or watch id.").withSchema(new Schema().withType(Type.STRING))));
}
/*******************************************************************************
** Versioned request schema.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (request == null ? null : new Schema().withRef("#/components/schemas/DataAnalysis" + request + "V1"));
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("DataAnalysis" + response + "V1");
}
/*******************************************************************************
** Include all referenced transport models and the shared core filter.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
return (this instanceof CapabilitiesSpec ? DataAnalysisSchemas.components() : Map.of());
}
}
/*******************************************************************************
** Existing table lookup before invoking permission-sensitive operations.
*******************************************************************************/
private static QTableMetaData table(String name) throws QNotFoundException
{
QTableMetaData table = QContext.getQInstance().getTable(name);
if(table == null)
{
throw new QNotFoundException("Unknown analysis table.");
}
return (table);
}
/*******************************************************************************
** Parse one JSON object with strict scalar/list types and core filter JSON.
*******************************************************************************/
private static AnalysisRequest request(String operation, String table, Map<String, Object> body) throws Exception
{
table(table);
DataAnalysisService service = DataAnalysisRuntime.service();
List<String> readable = service.capabilities(table).getFields();
try
{
for(String name : List.of("fieldName", "search"))
{
if(body.get(name) != null && !(body.get(name) instanceof String))
{
throw new IllegalArgumentException(name + " must be a string.");
}
}
if(body.get("exact") != null && !(body.get("exact") instanceof Boolean))
{
throw new IllegalArgumentException("exact must be a boolean.");
}
QQueryFilter filter = body.get("filter") == null ? null : JsonUtils.toObject(JsonUtils.toJson(body.get("filter")), QQueryFilter.class);
validateFilter(filter);
List<String> fields = null;
if(body.get("fields") != null)
{
if(!(body.get("fields") instanceof List<?> values) || values.stream().anyMatch(v -> !(v instanceof String)))
{
throw new IllegalArgumentException("fields must be an array of field names.");
}
fields = ((List<?>) body.get("fields")).stream().map(String.class::cast).toList();
if(fields.isEmpty() || !readable.containsAll(fields))
{
throw new IllegalArgumentException("Choose readable analysis fields.");
}
}
String field = (String) body.get("fieldName");
if((operation.equals("COLUMN_PROFILE") || operation.equals("DISTINCT_VALUES")) && !readable.contains(field))
{
throw new IllegalArgumentException("Choose a readable analysis field.");
}
boolean exact = Boolean.TRUE.equals(body.get("exact"));
return (switch(operation)
{
case "COLUMN_PROFILE" -> new ColumnProfileRequest().withTableName(table).withFilter(filter).withFieldName(field).withExact(exact);
case "TABLE_PROFILE" -> new TableProfileRequest().withTableName(table).withFilter(filter).withFields(fields);
case "DISTINCT_VALUES" -> new DistinctValuesRequest().withTableName(table).withFilter(filter).withFieldName(field)
.withPage(integer(body, "page", 0, 0, 1_000_000)).withPageSize(integer(body, "pageSize", 100, 1, 500)).withSearch((String) body.get("search"));
case "DUPLICATES" -> new DuplicatesRequest().withTableName(table).withFilter(filter)
.withFields(java.util.Objects.requireNonNull(fields, "Choose duplicate fields.")).withLimit(integer(body, "limit", 50, 1, 500));
default -> throw new IllegalArgumentException("operation must be COLUMN_PROFILE or TABLE_PROFILE.");
});
}
catch(Exception e)
{
throw new QBadRequestException("Invalid analysis request: " + e.getMessage());
}
}
/*******************************************************************************
** Bad filter fields fail before any aggregate or cached result is consulted.
*******************************************************************************/
private static void validateFilter(QQueryFilter filter)
{
if(filter == null)
{
return;
}
for(var criterion : filter.getCriteria())
{
if(criterion.getOperator() == null || criterion.getFieldName() == null)
{
throw new IllegalArgumentException("Choose readable filter fields and a valid operator.");
}
}
filter.getSubFilters().forEach(sub -> validateFilter(sub));
}
/*******************************************************************************
** Reject fractional, null, overflowing and out-of-range numeric parameters.
*******************************************************************************/
private static int integer(Map<String, Object> body, String name, int fallback, int min, int max)
{
if(!body.containsKey(name))
{
return (fallback);
}
Object value = body.get(name);
if(!(value instanceof Number number) || number.doubleValue() != number.intValue() || number.intValue() < min || number.intValue() > max)
{
throw new IllegalArgumentException(name + " must be an integer from " + min + " to " + max + ".");
}
return (number.intValue());
}
/*******************************************************************************
** Transport conversion avoids middleware dependencies in engine results.
*******************************************************************************/
private static <T> T transport(Object value, Class<T> type) throws Exception
{
return (JsonUtils.toObject(JsonUtils.toJson(value), type));
}
/*******************************************************************************
** Check analysis availability.
*******************************************************************************/
public static class CapabilitiesSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public CapabilitiesSpec()
{
super("/tables/{table}/capabilities", HttpMethod.GET, "Check analysis availability",
"Discover whether the current user can analyze this table, readable fields, chart defaults, limits and backend capabilities.", null, "Capabilities");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (capabilities(input.getPathParam("table")));
}
}
/*******************************************************************************
** Profile one column.
*******************************************************************************/
public static class ColumnProfileSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public ColumnProfileSpec()
{
super("/tables/{table}/column-profile", HttpMethod.POST, "Profile one column",
"Profile one column of a table under a filter: counts, nulls, distinct values, top values, numeric statistics and histogram, date patterns; every number has a drill-through filter unless privacy suppresses it.", "ColumnProfileRequest", "ColumnProfile");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().columnProfile((ColumnProfileRequest) request("COLUMN_PROFILE", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisColumnProfileV1.class));
}
}
/*******************************************************************************
** List distinct column values.
*******************************************************************************/
public static class DistinctValuesSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public DistinctValuesSpec()
{
super("/tables/{table}/distinct-values", HttpMethod.POST, "List distinct column values",
"Page through distinct stored values with display labels, counts and drill-through filters; search narrows stored values.", "DistinctValuesRequest", "DistinctValuesPage");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().distinctValues((DistinctValuesRequest) request("DISTINCT_VALUES", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisDistinctValuesPageV1.class));
}
}
/*******************************************************************************
** Profile table completeness and keys.
*******************************************************************************/
public static class TableProfileSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public TableProfileSpec()
{
super("/tables/{table}/table-profile", HttpMethod.POST, "Profile table completeness and keys",
"Profile readable fields under a filter for completeness, uniqueness, constant or empty columns and candidate keys.", "TableProfileRequest", "TableProfile");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().tableProfile((TableProfileRequest) request("TABLE_PROFILE", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisTableProfileV1.class));
}
}
/*******************************************************************************
** Find duplicate groups.
*******************************************************************************/
public static class DuplicatesSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public DuplicatesSpec()
{
super("/tables/{table}/duplicates", HttpMethod.POST, "Find duplicate groups",
"Group matching records by the selected readable fields and return repeated tuples with counts and drill-through filters.", "DuplicatesRequest", "DuplicatesResult");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().duplicates((DuplicatesRequest) request("DUPLICATES", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisDuplicatesResultV1.class));
}
}
/*******************************************************************************
** Start an exact profile run.
*******************************************************************************/
public static class StartRunSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public StartRunSpec()
{
super("/runs", HttpMethod.POST, "Start an exact profile run",
"Start an owner-scoped exact column or table profile. Poll the returned runId until COMPLETE or FAILED; results honor the current session permissions.", "StartRunRequest", "RunStarted");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
Map<String, Object> body = input.getBody();
if(body == null || !(body.get("tableName") instanceof String name) || !(body.get("operation") instanceof String operation)
|| !List.of("COLUMN_PROFILE", "TABLE_PROFILE").contains(operation) || !(body.get("request") instanceof Map<?, ?>))
{
throw new QBadRequestException("Supply operation, tableName and a profile request object.");
}
Map<String, Object> parameters = JsonUtils.toObject(JsonUtils.toJson(body.get("request")), java.util.LinkedHashMap.class);
String id = DataAnalysisRuntime.service().startExactRun(request(operation, name, parameters));
DataAnalysisSchemas.DataAnalysisRunStartedV1 result = new DataAnalysisSchemas.DataAnalysisRunStartedV1();
result.runId = id;
result.status = DataAnalysisRuntime.service().getRun(id).getStatus();
return (result);
}
}
/*******************************************************************************
** Get an exact profile run.
*******************************************************************************/
public static class GetRunSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public GetRunSpec()
{
super("/runs/{runId}", HttpMethod.GET, "Get an exact profile run",
"Read status, timestamps and the typed columnProfile or tableProfile result of an owner-scoped run. Completed results are rechecked against current permissions.", null, "RunStatus");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().getRun(input.getPathParam("runId")), DataAnalysisSchemas.DataAnalysisRunStatusV1.class));
}
}
/*******************************************************************************
** Permission probing is a successful response with no disclosed field list.
*******************************************************************************/
private static DataAnalysisSchemas.DataAnalysisCapabilitiesV1 capabilities(String name) throws Exception
{
QTableMetaData table = table(name);
DataAnalysisSchemas.DataAnalysisCapabilitiesV1 result = new DataAnalysisSchemas.DataAnalysisCapabilitiesV1();
result.tableName = name;
result.tableLabel = table.getLabel();
result.canAnalyze = false;
result.fields = List.of();
result.backendCapabilities = List.of();
result.sqlWorkbenchAvailable = com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.installedAndAllowed();
try
{
var capabilities = DataAnalysisRuntime.service().capabilities(name);
result.canAnalyze = true;
result.backendCapabilities = CoreAnalysisBackend.capabilities(name).stream().map(Enum::name).sorted().toList();
result.fields = capabilities.getFields().stream().map(f -> capabilityField(table.getField(f))).toList();
var config = capabilities.getConfig();
result.limits = new DataAnalysisSchemas.DataAnalysisLimitsV1();
result.limits.maxRows = config.getMaxRows();
result.limits.sampleThresholdRows = config.getSampleThresholdRows();
result.limits.sampleRows = config.getSampleRows();
result.limits.minGroupSize = config.getMinGroupSize();
}
catch(QPermissionDeniedException expected)
{
// A UI capability probe deliberately reports denial without an error.
}
return (result);
}
/*******************************************************************************
** Semantic type and the default visualization, with PVS/FK taking priority.
*******************************************************************************/
private static DataAnalysisSchemas.DataAnalysisCapabilityFieldV1 capabilityField(QFieldMetaData field)
{
DataAnalysisSchemas.DataAnalysisCapabilityFieldV1 result = new DataAnalysisSchemas.DataAnalysisCapabilityFieldV1();
result.name = field.getName();
result.label = field.getLabel();
result.type = field.getType().name();
result.kind = field.getPossibleValueSourceName() != null ? "CATEGORY" : switch(field.getType())
{
case INTEGER, LONG, DECIMAL -> "NUMERIC";
case DATE -> "DATE";
case DATE_TIME -> "DATE_TIME";
case BOOLEAN -> "BOOLEAN";
default -> "TEXT";
};
result.defaultChartKind = switch(result.kind)
{
case "NUMERIC" -> ChartKind.HISTOGRAM;
case "DATE", "DATE_TIME" -> ChartKind.TIME_SERIES;
default -> ChartKind.BAR;
};
return (result);
}
/*******************************************************************************
** Group records by row and column dimensions.
*******************************************************************************/
public static class PivotSpec extends ProfileSpec
{
/*******************************************************************************
** Publish assistant-friendly grouping and measure capabilities.
*******************************************************************************/
public PivotSpec()
{
super("/tables/{table}/pivot", HttpMethod.POST, "Explore a pivot table",
"Group records by up to 3 row and 2 column fields (with date or numeric buckets) and compute counts, sums, averages, min/max, distinct counts or percentiles per cell; every cell has a drill-through filter.", "PivotRequest", "Pivot");
}
/*******************************************************************************
** Strict body validation precedes the session-bound engine.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
table(tableName);
DataAnalysisRuntime.service().capabilities(tableName);
PivotRequest pivot;
try
{
Map<String, Object> body = input.getBody();
integer(body, "limitGroups", 50, 1, 200);
if(body.containsKey("showTotals") && !(body.get("showTotals") instanceof Boolean))
{
throw new IllegalArgumentException("showTotals must be a boolean.");
}
for(String axis : List.of("rows", "columns"))
{
if(body.containsKey(axis))
{
if(!(body.get(axis) instanceof List<?> dimensions))
{
throw new IllegalArgumentException("Dimensions must be arrays.");
}
for(Object dimension : dimensions)
{
if(!(dimension instanceof Map<?, ?> map) || !(map.get("field") instanceof String))
{
throw new IllegalArgumentException("Each dimension requires a field name.");
}
if(map.get("numericBins") instanceof Map<?, ?> bins)
{
Object count = bins.get("count");
if(count != null && (!(count instanceof Number number) || number.doubleValue() != number.intValue()))
{
throw new IllegalArgumentException("Bin count must be an integer.");
}
}
}
}
}
if(!(body.get("values") instanceof List<?> values) || values.stream().anyMatch(v -> !(v instanceof Map<?, ?> m)
|| !(m.get("operator") instanceof String) || m.get("field") != null && !(m.get("field") instanceof String)))
{
throw new IllegalArgumentException("Measures require an operator and optional field name.");
}
pivot = JsonUtils.toObject(JsonUtils.toJson(body), PivotRequest.class).withTableName(tableName);
validateFilter(pivot.getFilter());
}
catch(Exception e)
{
throw new QBadRequestException("Invalid pivot request.");
}
return (transport(DataAnalysisRuntime.service().pivot(pivot), DataAnalysisSchemas.DataAnalysisPivotV1.class));
}
}
/*******************************************************************************
** Strict JSON types before Jackson conversion; engine validates FULL fields.
*******************************************************************************/
private static <T> T relationshipRequest(String tableName, Map<String, Object> body, Class<T> type) throws Exception
{
table(tableName);
DataAnalysisRuntime.service().capabilities(tableName);
try
{
for(String name : List.of("field", "dateField", "bucket", "xField", "yField", "rowField", "columnField", "labelA", "labelB"))
{
if(body.containsKey(name) && !(body.get(name) instanceof String))
{
throw new IllegalArgumentException("Expected a field name or label.");
}
}
if(body.containsKey("fields") && (!(body.get("fields") instanceof List<?> fields) || fields.stream().anyMatch(f -> !(f instanceof String))))
{
throw new IllegalArgumentException("Expected an array of field names.");
}
if(body.containsKey("limit"))
{
integer(body, "limit", 2000, 1, type == CrosstabRequest.class ? 20 : 2000);
}
for(String name : List.of("filter", "filterA", "filterB"))
{
Object filter = body.get(name);
if(filter != null)
{
if(!(filter instanceof Map<?, ?>))
{
throw new IllegalArgumentException("Expected a query filter object.");
}
validateFilter(JsonUtils.toObject(JsonUtils.toJson(filter), QQueryFilter.class));
}
}
return (JsonUtils.toObject(JsonUtils.toJson(body), type));
}
catch(Exception e)
{
throw new QBadRequestException("Invalid relationship request.");
}
}
/*******************************************************************************
** Compare filtered A with filtered B (null means the whole permitted table). Return means, medians or category shares, B-minus-A deltas, valid significance tests and count-equivalent drills. Only FULL fields are accepted.
*******************************************************************************/
public static class CompareSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public CompareSpec()
{
super("/tables/{table}/compare", HttpMethod.POST, "Compare two populations",
"Compare filtered A with filtered B (null means the whole permitted table). Return means, medians or category shares, B-minus-A deltas, valid significance tests and count-equivalent drills. Only FULL fields are accepted.", "CompareRequest", "Compare");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
CompareRequest request = relationshipRequest(tableName, input.getBody(), CompareRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().compare(request), RelationshipSchemas.DataAnalysisCompareV1.class));
}
}
/*******************************************************************************
** Compare category shares or numeric quartiles across session-timezone calendar buckets. Compute descriptive PSI between the first and last complete nonempty buckets using fixed categories or baseline quartile bins.
*******************************************************************************/
public static class ShiftSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public ShiftSpec()
{
super("/tables/{table}/shift", HttpMethod.POST, "Measure distribution shift over time",
"Compare category shares or numeric quartiles across session-timezone calendar buckets. Compute descriptive PSI between the first and last complete nonempty buckets using fixed categories or baseline quartile bins.", "ShiftRequest", "Shift");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
ShiftRequest request = relationshipRequest(tableName, input.getBody(), ShiftRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().shift(request), RelationshipSchemas.DataAnalysisShiftV1.class));
}
}
/*******************************************************************************
** Compute a Pearson correlation matrix for 2 to 12 distinct FULL numeric fields, using paired non-null records. Every cell includes n and parameters for the scatter endpoint; core pushes down CORR or uses bounded streaming.
*******************************************************************************/
public static class CorrelationSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public CorrelationSpec()
{
super("/tables/{table}/correlation", HttpMethod.POST, "Find numeric relationships",
"Compute a Pearson correlation matrix for 2 to 12 distinct FULL numeric fields, using paired non-null records. Every cell includes n and parameters for the scatter endpoint; core pushes down CORR or uses bounded streaming.", "CorrelationRequest", "Correlation");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
CorrelationRequest request = relationshipRequest(tableName, input.getBody(), CorrelationRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().correlation(request), RelationshipSchemas.DataAnalysisCorrelationV1.class));
}
}
/*******************************************************************************
** Return at most 2000 paired numeric points as the user, ordered by primary key when core query sampling is unavailable, plus a least-squares line fitted to the sample. Privacy disables individual drills and suppresses small populations.
*******************************************************************************/
public static class ScatterSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public ScatterSpec()
{
super("/tables/{table}/scatter", HttpMethod.POST, "Inspect a numeric relationship",
"Return at most 2000 paired numeric points as the user, ordered by primary key when core query sampling is unavailable, plus a least-squares line fitted to the sample. Privacy disables individual drills and suppresses small populations.", "ScatterRequest", "Scatter");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
ScatterRequest request = relationshipRequest(tableName, input.getBody(), ScatterRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().scatter(request), RelationshipSchemas.DataAnalysisScatterV1.class));
}
}
/*******************************************************************************
** Count combinations of two FULL fields with row and column shares, up to 20 categories per axis plus Other. Test independence using chi-square and Cramer V after pooling low expected counts; privacy suppresses small cells and their drills.
*******************************************************************************/
public static class CrosstabSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public CrosstabSpec()
{
super("/tables/{table}/crosstab", HttpMethod.POST, "Cross-tabulate two fields",
"Count combinations of two FULL fields with row and column shares, up to 20 categories per axis plus Other. Test independence using chi-square and Cramer V after pooling low expected counts; privacy suppresses small cells and their drills.", "CrosstabRequest", "Crosstab");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
CrosstabRequest request = relationshipRequest(tableName, input.getBody(), CrosstabRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().crosstab(request), RelationshipSchemas.DataAnalysisCrosstabV1.class));
}
}
/*******************************************************************************
** Read-only watch preview; standard table endpoints own watch CRUD.
*******************************************************************************/
public static class WatchPreviewSpec extends ProfileSpec
{
/*******************************************************************************
** Publish a side-effect-free detector preview for a saved watch.
*******************************************************************************/
public WatchPreviewSpec()
{
super("/watches/{id}/preview", HttpMethod.POST, "Preview an anomaly watch",
"Compute complete time buckets and a detector verdict under the signed-in user's READ, Analyze and FULL-field permissions. Includes a baseline band annotation. Does not update the watch or raise alerts.", null, "WatchPreview");
}
/*******************************************************************************
** Missing or inaccessible watch ids return the same not-found response.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
Integer id;
try
{
id = Integer.valueOf(input.getPathParam("id"));
}
catch(NumberFormatException e)
{
throw new QBadRequestException("Watch id must be an integer.");
}
return (transport(DataAnalysisRuntime.watches().preview(id, java.time.Instant.now()), WatchSchemas.DataAnalysisWatchPreviewV1.class));
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,663 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.api;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.annotations.OpenAPIDescription;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Explicit version-one contracts for compare and relationships.
*******************************************************************************/
public class RelationshipSchemas
{
/*******************************************************************************
** Register every referenced response and request component.
*******************************************************************************/
public static Map<String, Schema> components()
{
Map<String, Schema> result = new LinkedHashMap<>();
result.put("DataAnalysisCompareRequestV1", new DataAnalysisCompareRequestV1().toSchema());
result.put("DataAnalysisShiftRequestV1", new DataAnalysisShiftRequestV1().toSchema());
result.put("DataAnalysisCorrelationRequestV1", new DataAnalysisCorrelationRequestV1().toSchema());
result.put("DataAnalysisScatterRequestV1", new DataAnalysisScatterRequestV1().toSchema());
result.put("DataAnalysisCrosstabRequestV1", new DataAnalysisCrosstabRequestV1().toSchema());
result.put("DataAnalysisSignificanceV1", new DataAnalysisSignificanceV1().toSchema());
result.put("DataAnalysisComparisonSideV1", new DataAnalysisComparisonSideV1().toSchema());
result.put("DataAnalysisComparisonValueV1", new DataAnalysisComparisonValueV1().toSchema());
result.put("DataAnalysisFieldComparisonV1", new DataAnalysisFieldComparisonV1().toSchema());
result.put("DataAnalysisCompareV1", new DataAnalysisCompareV1().toSchema());
result.put("DataAnalysisShiftBucketV1", new DataAnalysisShiftBucketV1().toSchema());
result.put("DataAnalysisShiftV1", new DataAnalysisShiftV1().toSchema());
result.put("DataAnalysisCorrelationCellV1", new DataAnalysisCorrelationCellV1().toSchema());
result.put("DataAnalysisCorrelationV1", new DataAnalysisCorrelationV1().toSchema());
result.put("DataAnalysisTrendLineV1", new DataAnalysisTrendLineV1().toSchema());
result.put("DataAnalysisScatterV1", new DataAnalysisScatterV1().toSchema());
result.put("DataAnalysisCrosstabCellV1", new DataAnalysisCrosstabCellV1().toSchema());
result.put("DataAnalysisCrosstabV1", new DataAnalysisCrosstabV1().toSchema());
return (result);
}
/*******************************************************************************
** Reuse the common metric, filter and chart contracts.
*******************************************************************************/
private static Schema ref(String name)
{
return (new Schema().withRef("#/components/schemas/" + name));
}
/*******************************************************************************
** CompareRequest version-one transport model.
*******************************************************************************/
public static class DataAnalysisCompareRequestV1 implements ToSchema
{
@OpenAPIDescription("Population A filter; null selects the whole permitted table.")
public QQueryFilter filterA;
@OpenAPIDescription("Population B filter; null selects the whole permitted table.")
public QQueryFilter filterB;
@OpenAPIDescription("Display name of population A; default A.")
public String labelA;
@OpenAPIDescription("Display name of population B; default B.")
public String labelB;
@OpenAPIDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields.")
public List<String> fields;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("filterA", ref("QueryFilter").withDescription("Population A filter; null selects the whole permitted table.").withNullable(true));
properties.put("filterB", ref("QueryFilter").withDescription("Population B filter; null selects the whole permitted table.").withNullable(true));
properties.put("labelA", new Schema().withType(Type.STRING).withDescription("Display name of population A; default A."));
properties.put("labelB", new Schema().withType(Type.STRING).withDescription("Display name of population B; default B."));
properties.put("fields", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)).withDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields."));
return (new DataAnalysisSchemas.RequiredSchema(List.of("fields")).withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** ShiftRequest version-one transport model.
*******************************************************************************/
public static class DataAnalysisShiftRequestV1 implements ToSchema
{
@OpenAPIDescription("Current-user query filter; record locks and core field rules apply.")
public QQueryFilter filter;
@OpenAPIDescription("FULL field whose distribution is compared over time.")
public String field;
@OpenAPIDescription("FULL DATE or DATE_TIME field for session-timezone calendar buckets.")
public String dateField;
@OpenAPIDescription("Calendar unit: HOUR, DAY, WEEK, MONTH, QUARTER or YEAR; default MONTH.")
public String bucket;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("filter", ref("QueryFilter").withDescription("Current-user query filter; record locks and core field rules apply.").withNullable(true));
properties.put("field", new Schema().withType(Type.STRING).withDescription("FULL field whose distribution is compared over time."));
properties.put("dateField", new Schema().withType(Type.STRING).withDescription("FULL DATE or DATE_TIME field for session-timezone calendar buckets."));
properties.put("bucket", new Schema().withType(Type.STRING).withDescription("Calendar unit: HOUR, DAY, WEEK, MONTH, QUARTER or YEAR; default MONTH."));
return (new DataAnalysisSchemas.RequiredSchema(List.of("field", "dateField")).withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** CorrelationRequest version-one transport model.
*******************************************************************************/
public static class DataAnalysisCorrelationRequestV1 implements ToSchema
{
@OpenAPIDescription("Current-user query filter; record locks and core field rules apply.")
public QQueryFilter filter;
@OpenAPIDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields.")
public List<String> fields;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("filter", ref("QueryFilter").withDescription("Current-user query filter; record locks and core field rules apply.").withNullable(true));
properties.put("fields", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)).withDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields."));
return (new DataAnalysisSchemas.RequiredSchema(List.of("fields")).withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** ScatterRequest version-one transport model.
*******************************************************************************/
public static class DataAnalysisScatterRequestV1 implements ToSchema
{
@OpenAPIDescription("Table for the scatter link; route parameter takes precedence.")
public String tableName;
@OpenAPIDescription("Compatibility flag; scatter always returns the bounded sample.")
public Boolean exact;
@OpenAPIDescription("Current-user query filter; record locks and core field rules apply.")
public QQueryFilter filter;
@OpenAPIDescription("FULL numeric field on the x axis.")
public String xField;
@OpenAPIDescription("FULL numeric field on the y axis.")
public String yField;
@OpenAPIDescription("Maximum sample points (1 to 2000, default 2000) or kept crosstab categories per axis (1 to 20, default 20).")
public Integer limit;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("tableName", new Schema().withType(Type.STRING).withDescription("Table for the scatter link; route parameter takes precedence.").withNullable(true));
properties.put("exact", new Schema().withType(Type.BOOLEAN).withDescription("Compatibility flag; scatter always returns the bounded sample.").withNullable(true));
properties.put("filter", ref("QueryFilter").withDescription("Current-user query filter; record locks and core field rules apply.").withNullable(true));
properties.put("xField", new Schema().withType(Type.STRING).withDescription("FULL numeric field on the x axis."));
properties.put("yField", new Schema().withType(Type.STRING).withDescription("FULL numeric field on the y axis."));
properties.put("limit", new Schema().withType(Type.INTEGER).withDescription("Maximum sample points (1 to 2000, default 2000) or kept crosstab categories per axis (1 to 20, default 20)."));
return (new DataAnalysisSchemas.RequiredSchema(List.of("xField", "yField")).withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** CrosstabRequest version-one transport model.
*******************************************************************************/
public static class DataAnalysisCrosstabRequestV1 implements ToSchema
{
@OpenAPIDescription("Current-user query filter; record locks and core field rules apply.")
public QQueryFilter filter;
@OpenAPIDescription("FULL field defining crosstab rows.")
public String rowField;
@OpenAPIDescription("FULL field defining crosstab columns.")
public String columnField;
@OpenAPIDescription("Maximum sample points (1 to 2000, default 2000) or kept crosstab categories per axis (1 to 20, default 20).")
public Integer limit;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("filter", ref("QueryFilter").withDescription("Current-user query filter; record locks and core field rules apply.").withNullable(true));
properties.put("rowField", new Schema().withType(Type.STRING).withDescription("FULL field defining crosstab rows."));
properties.put("columnField", new Schema().withType(Type.STRING).withDescription("FULL field defining crosstab columns."));
properties.put("limit", new Schema().withType(Type.INTEGER).withDescription("Maximum sample points (1 to 2000, default 2000) or kept crosstab categories per axis (1 to 20, default 20)."));
return (new DataAnalysisSchemas.RequiredSchema(List.of("rowField", "columnField")).withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** Significance version-one transport model.
*******************************************************************************/
public static class DataAnalysisSignificanceV1 implements ToSchema
{
@OpenAPIDescription("Test.")
public String test;
@OpenAPIDescription("Statistic.")
public Double statistic;
@OpenAPIDescription("Degrees of freedom; null when undefined.")
public Double df;
@OpenAPIDescription("Two-sided p-value; p >= 0.05 is never significant.")
public Double pValue;
@OpenAPIDescription("Whether all test validity and privacy requirements hold.")
public Boolean applicable;
@OpenAPIDescription("Plain-language result or reason the statistic is not applicable.")
public String sentence;
@OpenAPIDescription("Cramer association strength computed on the pooled contingency table; null when invalid.")
public Double cramersV;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("test", new Schema().withType(Type.STRING).withDescription("Test.").withNullable(true));
properties.put("statistic", new Schema().withType(Type.NUMBER).withDescription("Statistic.").withNullable(true));
properties.put("df", new Schema().withType(Type.NUMBER).withDescription("Degrees of freedom; null when undefined.").withNullable(true));
properties.put("pValue", new Schema().withType(Type.NUMBER).withDescription("Two-sided p-value; p >= 0.05 is never significant.").withNullable(true));
properties.put("applicable", new Schema().withType(Type.BOOLEAN).withDescription("Whether all test validity and privacy requirements hold.").withNullable(true));
properties.put("sentence", new Schema().withType(Type.STRING).withDescription("Plain-language result or reason the statistic is not applicable.").withNullable(true));
properties.put("cramersV", new Schema().withType(Type.NUMBER).withDescription("Cramer association strength computed on the pooled contingency table; null when invalid.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** ComparisonSide version-one transport model.
*******************************************************************************/
public static class DataAnalysisComparisonSideV1 implements ToSchema
{
@OpenAPIDescription("Label.")
public String label;
@OpenAPIDescription("Typed metrics with exact population drills unless privacy suppresses them.")
public List<DataAnalysisSchemas.DataAnalysisMetricV1> metrics;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("label", new Schema().withType(Type.STRING).withDescription("Label.").withNullable(true));
properties.put("metrics", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisMetricV1")).withDescription("Typed metrics with exact population drills unless privacy suppresses them.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** ComparisonValue version-one transport model.
*******************************************************************************/
public static class DataAnalysisComparisonValueV1 implements ToSchema
{
@OpenAPIDescription("Value.")
public Serializable value;
@OpenAPIDescription("Label.")
public String label;
@OpenAPIDescription("Side a.")
public DataAnalysisComparisonSideV1 sideA;
@OpenAPIDescription("Side b.")
public DataAnalysisComparisonSideV1 sideB;
@OpenAPIDescription("B minus A, in original units (share differences are fractions).")
public DataAnalysisSchemas.DataAnalysisMetricV1 delta;
@OpenAPIDescription("(B minus A) divided by absolute A, a fraction; null at a zero baseline.")
public DataAnalysisSchemas.DataAnalysisMetricV1 percentDelta;
@OpenAPIDescription("Test assumptions, two-sided p-value, degrees of freedom and plain-language conclusion.")
public DataAnalysisSignificanceV1 significance;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("value", new Schema().withDescription("Value.").withNullable(true));
properties.put("label", new Schema().withType(Type.STRING).withDescription("Label.").withNullable(true));
properties.put("sideA", ref("DataAnalysisComparisonSideV1").withDescription("Side a.").withNullable(true));
properties.put("sideB", ref("DataAnalysisComparisonSideV1").withDescription("Side b.").withNullable(true));
properties.put("delta", ref("DataAnalysisMetricV1").withDescription("B minus A, in original units (share differences are fractions).").withNullable(true));
properties.put("percentDelta", ref("DataAnalysisMetricV1").withDescription("(B minus A) divided by absolute A, a fraction; null at a zero baseline.").withNullable(true));
properties.put("significance", ref("DataAnalysisSignificanceV1").withDescription("Test assumptions, two-sided p-value, degrees of freedom and plain-language conclusion.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** FieldComparison version-one transport model.
*******************************************************************************/
public static class DataAnalysisFieldComparisonV1 implements ToSchema
{
@OpenAPIDescription("FULL field whose distribution is compared over time.")
public String field;
@OpenAPIDescription("Values.")
public List<DataAnalysisComparisonValueV1> values;
@OpenAPIDescription("Test assumptions, two-sided p-value, degrees of freedom and plain-language conclusion.")
public DataAnalysisSignificanceV1 significance;
@OpenAPIDescription("Semantic series; category comparisons are grouped and category time series are stacked.")
public DataAnalysisSchemas.DataAnalysisChartV1 chart;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("field", new Schema().withType(Type.STRING).withDescription("FULL field whose distribution is compared over time.").withNullable(true));
properties.put("values", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisComparisonValueV1")).withDescription("Values.").withNullable(true));
properties.put("significance", ref("DataAnalysisSignificanceV1").withDescription("Test assumptions, two-sided p-value, degrees of freedom and plain-language conclusion.").withNullable(true));
properties.put("chart", ref("DataAnalysisChartV1").withDescription("Semantic series; category comparisons are grouped and category time series are stacked.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** CompareResult version-one transport model.
*******************************************************************************/
public static class DataAnalysisCompareV1 implements ToSchema
{
@OpenAPIDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields.")
public List<DataAnalysisFieldComparisonV1> fields;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("fields", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisFieldComparisonV1")).withDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** ShiftBucket version-one transport model.
*******************************************************************************/
public static class DataAnalysisShiftBucketV1 implements ToSchema
{
@OpenAPIDescription("Value.")
public Serializable value;
@OpenAPIDescription("Label.")
public String label;
@OpenAPIDescription("True when the calendar bucket end has passed in the session timezone.")
public Boolean complete;
@OpenAPIDescription("Typed metrics with exact population drills unless privacy suppresses them.")
public List<DataAnalysisSchemas.DataAnalysisMetricV1> metrics;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("value", new Schema().withDescription("Value.").withNullable(true));
properties.put("label", new Schema().withType(Type.STRING).withDescription("Label.").withNullable(true));
properties.put("complete", new Schema().withType(Type.BOOLEAN).withDescription("True when the calendar bucket end has passed in the session timezone.").withNullable(true));
properties.put("metrics", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisMetricV1")).withDescription("Typed metrics with exact population drills unless privacy suppresses them.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** ShiftResult version-one transport model.
*******************************************************************************/
public static class DataAnalysisShiftV1 implements ToSchema
{
@OpenAPIDescription("Buckets.")
public List<DataAnalysisShiftBucketV1> buckets;
@OpenAPIDescription("Population stability index, null when inapplicable; zero shares are floored at 0.000001.")
public DataAnalysisSchemas.DataAnalysisMetricV1 psi;
@OpenAPIDescription("Plain-language result or reason the statistic is not applicable.")
public String sentence;
@OpenAPIDescription("Semantic series; category comparisons are grouped and category time series are stacked.")
public DataAnalysisSchemas.DataAnalysisChartV1 chart;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("buckets", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisShiftBucketV1")).withDescription("Buckets.").withNullable(true));
properties.put("psi", ref("DataAnalysisMetricV1").withDescription("Population stability index, null when inapplicable; zero shares are floored at 0.000001.").withNullable(true));
properties.put("sentence", new Schema().withType(Type.STRING).withDescription("Plain-language result or reason the statistic is not applicable.").withNullable(true));
properties.put("chart", ref("DataAnalysisChartV1").withDescription("Semantic series; category comparisons are grouped and category time series are stacked.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** CorrelationCell version-one transport model.
*******************************************************************************/
public static class DataAnalysisCorrelationCellV1 implements ToSchema
{
@OpenAPIDescription("FULL numeric field on the x axis.")
public String xField;
@OpenAPIDescription("FULL numeric field on the y axis.")
public String yField;
@OpenAPIDescription("Pairwise non-null population count; null when privacy suppresses it.")
public Long n;
@OpenAPIDescription("Pearson correlation; null for insufficient data, zero variance or privacy suppression.")
public Double r;
@OpenAPIDescription("POST the included parameters to the table scatter endpoint.")
public DataAnalysisScatterRequestV1 scatter;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("xField", new Schema().withType(Type.STRING).withDescription("FULL numeric field on the x axis.").withNullable(true));
properties.put("yField", new Schema().withType(Type.STRING).withDescription("FULL numeric field on the y axis.").withNullable(true));
properties.put("n", new Schema().withType(Type.INTEGER).withDescription("Pairwise non-null population count; null when privacy suppresses it.").withNullable(true));
properties.put("r", new Schema().withType(Type.NUMBER).withDescription("Pearson correlation; null for insufficient data, zero variance or privacy suppression.").withNullable(true));
properties.put("scatter", ref("DataAnalysisScatterRequestV1").withDescription("POST the included parameters to the table scatter endpoint.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** CorrelationResult version-one transport model.
*******************************************************************************/
public static class DataAnalysisCorrelationV1 implements ToSchema
{
@OpenAPIDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields.")
public List<String> fields;
@OpenAPIDescription("Cells.")
public List<DataAnalysisCorrelationCellV1> cells;
@OpenAPIDescription("Semantic series; category comparisons are grouped and category time series are stacked.")
public DataAnalysisSchemas.DataAnalysisChartV1 chart;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("fields", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)).withDescription("Distinct FULL fields to analyze; correlation requires 2 to 12 numeric fields.").withNullable(true));
properties.put("cells", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisCorrelationCellV1")).withDescription("Cells.").withNullable(true));
properties.put("chart", ref("DataAnalysisChartV1").withDescription("Semantic series; category comparisons are grouped and category time series are stacked.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** TrendLine version-one transport model.
*******************************************************************************/
public static class DataAnalysisTrendLineV1 implements ToSchema
{
@OpenAPIDescription("Slope.")
public Double slope;
@OpenAPIDescription("Intercept.")
public Double intercept;
@OpenAPIDescription("Pearson correlation; null for insufficient data, zero variance or privacy suppression.")
public Double r;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("slope", new Schema().withType(Type.NUMBER).withDescription("Slope.").withNullable(true));
properties.put("intercept", new Schema().withType(Type.NUMBER).withDescription("Intercept.").withNullable(true));
properties.put("r", new Schema().withType(Type.NUMBER).withDescription("Pearson correlation; null for insufficient data, zero variance or privacy suppression.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** ScatterResult version-one transport model.
*******************************************************************************/
public static class DataAnalysisScatterV1 implements ToSchema
{
@OpenAPIDescription("Pairwise non-null population count; null when privacy suppresses it.")
public Long n;
@OpenAPIDescription("Number of displayed paired records.")
public Integer sampleSize;
@OpenAPIDescription("Sampling method; core query currently uses a deterministic primary-key prefix.")
public String sampling;
@OpenAPIDescription("Points.")
public List<DataAnalysisSchemas.DataAnalysisPointV1> points;
@OpenAPIDescription("Least-squares slope, intercept and r fitted to the displayed sample.")
public DataAnalysisTrendLineV1 trend;
@OpenAPIDescription("Semantic series; category comparisons are grouped and category time series are stacked.")
public DataAnalysisSchemas.DataAnalysisChartV1 chart;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("n", new Schema().withType(Type.INTEGER).withDescription("Pairwise non-null population count; null when privacy suppresses it.").withNullable(true));
properties.put("sampleSize", new Schema().withType(Type.INTEGER).withDescription("Number of displayed paired records.").withNullable(true));
properties.put("sampling", new Schema().withType(Type.STRING).withDescription("Sampling method; core query currently uses a deterministic primary-key prefix.").withNullable(true));
properties.put("points", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisPointV1")).withDescription("Points.").withNullable(true));
properties.put("trend", ref("DataAnalysisTrendLineV1").withDescription("Least-squares slope, intercept and r fitted to the displayed sample.").withNullable(true));
properties.put("chart", ref("DataAnalysisChartV1").withDescription("Semantic series; category comparisons are grouped and category time series are stacked.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** CrosstabCell version-one transport model.
*******************************************************************************/
public static class DataAnalysisCrosstabCellV1 implements ToSchema
{
@OpenAPIDescription("Row index.")
public Integer rowIndex;
@OpenAPIDescription("Column index.")
public Integer columnIndex;
@OpenAPIDescription("Cell count with its exact drill, or a suppressed metric.")
public DataAnalysisSchemas.DataAnalysisMetricV1 count;
@OpenAPIDescription("Cell count divided by row total; suppressed if it could disclose a hidden cell.")
public DataAnalysisSchemas.DataAnalysisMetricV1 rowShare;
@OpenAPIDescription("Cell count divided by column total; suppressed if it could disclose a hidden cell.")
public DataAnalysisSchemas.DataAnalysisMetricV1 columnShare;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("rowIndex", new Schema().withType(Type.INTEGER).withDescription("Row index.").withNullable(true));
properties.put("columnIndex", new Schema().withType(Type.INTEGER).withDescription("Column index.").withNullable(true));
properties.put("count", ref("DataAnalysisMetricV1").withDescription("Cell count with its exact drill, or a suppressed metric.").withNullable(true));
properties.put("rowShare", ref("DataAnalysisMetricV1").withDescription("Cell count divided by row total; suppressed if it could disclose a hidden cell.").withNullable(true));
properties.put("columnShare", ref("DataAnalysisMetricV1").withDescription("Cell count divided by column total; suppressed if it could disclose a hidden cell.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** CrosstabResult version-one transport model.
*******************************************************************************/
public static class DataAnalysisCrosstabV1 implements ToSchema
{
@OpenAPIDescription("Rows.")
public List<DataAnalysisSchemas.DataAnalysisPivotHeaderV1> rows;
@OpenAPIDescription("Columns.")
public List<DataAnalysisSchemas.DataAnalysisPivotHeaderV1> columns;
@OpenAPIDescription("Cells.")
public List<DataAnalysisCrosstabCellV1> cells;
@OpenAPIDescription("Test assumptions, two-sided p-value, degrees of freedom and plain-language conclusion.")
public DataAnalysisSignificanceV1 significance;
/*******************************************************************************
** Explicit references preserve the standalone OpenAPI fragment.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("rows", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisPivotHeaderV1")).withDescription("Rows.").withNullable(true));
properties.put("columns", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisPivotHeaderV1")).withDescription("Columns.").withNullable(true));
properties.put("cells", new Schema().withType(Type.ARRAY).withItems(ref("DataAnalysisCrosstabCellV1")).withDescription("Cells.").withNullable(true));
properties.put("significance", ref("DataAnalysisSignificanceV1").withDescription("Test assumptions, two-sided p-value, degrees of freedom and plain-language conclusion.").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
}
@@ -0,0 +1,208 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.api;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.saved.SavedDefinition;
import com.kingsrook.qbits.dataanalysis.saved.SqlHandoff;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.openapi.model.HttpMethod;
/*******************************************************************************
** Saved execution, stars, visible lists and SQL discovery through the UI API.
*******************************************************************************/
public final class SavedApi
{
/*******************************************************************************
** Namespace only.
*******************************************************************************/
private SavedApi()
{
}
/*******************************************************************************
** Stable integer-id errors, without probing inaccessible records.
*******************************************************************************/
public static Integer id(String value) throws QBadRequestException
{
try
{
return (Integer.valueOf(value));
}
catch(Exception e)
{
throw new QBadRequestException("Saved analysis id must be an integer.");
}
}
/*******************************************************************************
** Accept a discriminated typed request; path table overrides only absent tableName.
*******************************************************************************/
@SuppressWarnings("unchecked")
public static SavedDefinition definition(Map<String, Object> body, String pathTable) throws QException
{
if(!(body.get("kind") instanceof String kind))
{
throw new QBadRequestException("An analysis kind is required.");
}
Map<String, Object> request = new LinkedHashMap<>(body);
request.remove("kind");
request.remove("format");
if(body.containsKey("request"))
{
if(!(body.get("request") instanceof Map<?, ?> map))
{
throw new QBadRequestException("request must be a typed analysis object.");
}
request = new LinkedHashMap<>((Map<String, Object>) map);
}
Object table = pathTable == null ? body.getOrDefault("tableName", request.get("tableName")) : pathTable;
if(!(table instanceof String name))
{
throw new QBadRequestException("tableName is required.");
}
return (SavedDefinition.parse(kind, name, request));
}
/*******************************************************************************
** Run the visible saved definition under the viewer's current source permissions.
*******************************************************************************/
public static class Run extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** Publish assistant-oriented semantics and field-reduction behavior.
*******************************************************************************/
public Run()
{
super("/saved/{id}/run", HttpMethod.POST, "Run a saved analysis",
"Re-run a visible saved analysis as the signed-in viewer. Requires saved READ and source READ/Analyze. Drops unavailable fields with a warning and returns the result typed by kind; inaccessible required axes or predicates produce no result.", null, "SavedResult");
}
/*******************************************************************************
** Core locks determine visibility, not an endpoint-specific owner check.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (DataAnalysisRuntime.saved().run(id(input.getPathParam("id"))));
}
}
/*******************************************************************************
** Visible saved metadata with this viewer's personal stars.
*******************************************************************************/
public static class ListSaved extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** A discoverable list rather than exposing the owner's cached result JSON.
*******************************************************************************/
public ListSaved()
{
super("/saved", HttpMethod.GET, "List visible saved analyses",
"List core-visible saved analyses with name, kind, source table label, owner, shared state, this viewer's starred flag and last update.", null, "SavedList");
}
/*******************************************************************************
** The list and star lookup both use enforced core queries.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (Map.of("analyses", DataAnalysisRuntime.saved().list()));
}
}
/*******************************************************************************
** Idempotent star PUT for the signed-in user.
*******************************************************************************/
public static class Star extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** No user-id body is accepted; stars always belong to the caller.
*******************************************************************************/
public Star()
{
super("/saved/{id}/star", HttpMethod.PUT, "Star a saved analysis",
"Star a visible saved analysis for the signed-in user. Idempotent; uses standard saved insert permission and the personal star record lock.", null, "Star");
}
/*******************************************************************************
** Persist through core table CRUD.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (Map.of("starred", DataAnalysisRuntime.saved().star(id(input.getPathParam("id")), true)));
}
}
/*******************************************************************************
** Idempotent star DELETE for the signed-in user.
*******************************************************************************/
public static class Unstar extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** Removal never targets another user's star.
*******************************************************************************/
public Unstar()
{
super("/saved/{id}/star", HttpMethod.DELETE, "Unstar a saved analysis",
"Remove the signed-in user's star on a visible saved analysis. Idempotent; uses standard saved delete permission and owner locks.", null, "Star");
}
/*******************************************************************************
** Persist through core table CRUD.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (Map.of("starred", DataAnalysisRuntime.saved().star(id(input.getPathParam("id")), false)));
}
}
/*******************************************************************************
** Actual backend aggregate SQL, captured only during this request.
*******************************************************************************/
public static class Sql extends DataAnalysisApi.ProfileSpec
{
/*******************************************************************************
** The assistant gets a reading aid, never an alternative execution endpoint.
*******************************************************************************/
public Sql()
{
super("/tables/{table}/sql", HttpMethod.POST, "Read an analysis aggregate SQL statement",
"Execute a typed analysis as the viewer and capture the main RDBMS aggregate SQL from core QueryStat. Requires installed SQL Workbench and its access permission. Non-RDBMS backends report unavailable; SQL is for reading and record security remains enforced at runtime.", "SavedRequest", "Sql");
}
/*******************************************************************************
** Requests pass through the same parser and analysis authorization.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (SqlHandoff.execute(definition(input.getBody(), input.getPathParam("table")), DataAnalysisRuntime.service()));
}
}
}
@@ -0,0 +1,99 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.api;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Versioned saved envelopes reuse all seven existing typed request/result schemas.
*******************************************************************************/
public final class SavedSchemas
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private SavedSchemas()
{
}
/*******************************************************************************
** JSON Schema union supported by the standard OpenAPI serializer.
*******************************************************************************/
public static class RequestUnion extends Schema
{
/*******************************************************************************
** All supported typed analysis requests, selected by the sibling kind.
*******************************************************************************/
public List<Schema> getAnyOf()
{
return (List.of("ColumnProfile", "TableProfile", "Pivot", "Compare", "Correlation", "Crosstab", "Shift").stream()
.map(name -> ref(name + "Request")).toList());
}
}
/*******************************************************************************
** Every new endpoint's request and response is discoverable by clients.
*******************************************************************************/
public static Map<String, Schema> components()
{
Map<String, Schema> result = new LinkedHashMap<>();
Map<String, Schema> fields = new LinkedHashMap<>();
fields.put("kind", new Schema().withType(Type.STRING).withEnumValues(List.of("COLUMN_PROFILE", "TABLE_PROFILE", "PIVOT", "COMPARE", "CORRELATION", "CROSSTAB", "SHIFT")));
fields.put("tableName", new Schema().withType(Type.STRING).withDescription("Source QQQ table name; required for ad hoc export, supplied by the path for SQL."));
fields.put("request", new RequestUnion().withDescription("Typed request matching kind. Field names, filters and dimensions use the existing analysis request contracts."));
fields.put("format", new Schema().withType(Type.STRING).withEnumValues(List.of("csv", "xlsx")).withDescription("Required for export; ignored for SQL."));
result.put("DataAnalysisSavedRequestV1", new DataAnalysisSchemas.RequiredSchema(List.of("kind", "request")).withType(Type.OBJECT).withProperties(fields));
Map<String, Schema> response = new LinkedHashMap<>();
response.put("kind", fields.get("kind"));
response.put("warnings", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)));
new java.util.TreeMap<>(Map.of("columnProfile", "ColumnProfile", "tableProfile", "TableProfile", "pivot", "Pivot", "compare", "Compare",
"correlation", "Correlation", "crosstab", "Crosstab", "shift", "Shift")).forEach((key, value) -> response.put(key, ref(value).withNullable(true)));
result.put("DataAnalysisSavedResultV1", new Schema().withType(Type.OBJECT).withProperties(response));
Map<String, Schema> entry = new LinkedHashMap<>();
entry.put("id", new Schema().withType(Type.INTEGER));
for(String name : List.of("name", "kind", "tableName", "tableLabel", "owner", "updated"))
{
entry.put(name, new Schema().withType(Type.STRING));
}
entry.put("updated", new Schema().withType(Type.STRING).withFormat("date-time").withNullable(true));
entry.put("shared", new Schema().withType(Type.BOOLEAN));
entry.put("starred", new Schema().withType(Type.BOOLEAN));
result.put("DataAnalysisSavedEntryV1", new Schema().withType(Type.OBJECT).withProperties(entry));
result.put("DataAnalysisSavedListV1", new Schema().withType(Type.OBJECT).withProperties(new java.util.TreeMap<>(Map.of("analyses", new Schema().withType(Type.ARRAY).withItems(ref("SavedEntry"))))));
result.put("DataAnalysisStarV1", new Schema().withType(Type.OBJECT).withProperties(new java.util.TreeMap<>(Map.of("starred", new Schema().withType(Type.BOOLEAN)))));
result.put("DataAnalysisSqlV1", new Schema().withType(Type.OBJECT).withProperties(new java.util.TreeMap<>(Map.of("available", new Schema().withType(Type.BOOLEAN),
"sql", new Schema().withType(Type.STRING).withNullable(true), "note", new Schema().withType(Type.STRING)))));
result.put("DataAnalysisFileV1", new Schema().withType(Type.STRING).withFormat("binary"));
return (result);
}
/*******************************************************************************
** Reuse the extension's stable schema prefix.
*******************************************************************************/
private static Schema ref(String name)
{
return (new Schema().withRef("#/components/schemas/DataAnalysis" + name + "V1"));
}
}
@@ -0,0 +1,126 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.api;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Standalone watch preview contracts referencing the existing chart and drill.
*******************************************************************************/
public class WatchSchemas
{
/*******************************************************************************
** Register every referenced version-one watch response component.
*******************************************************************************/
public static Map<String, Schema> components()
{
return (Map.of("DataAnalysisWatchPreviewV1", new DataAnalysisWatchPreviewV1().toSchema(),
"DataAnalysisWatchBucketV1", new DataAnalysisWatchBucketV1().toSchema(),
"DataAnalysisWatchVerdictV1", new DataAnalysisWatchVerdictV1().toSchema()));
}
/*******************************************************************************
** One complete bucket; suppressed series never expose categories or drills.
*******************************************************************************/
public static class DataAnalysisWatchBucketV1 implements ToSchema
{
public Serializable start;
public Double value;
public List<String> categories;
public DataAnalysisSchemas.DataAnalysisDrillV1 drill;
/*******************************************************************************
** Explicit nullable types and component references.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("start", new Schema().withType(Type.STRING).withDescription("Calendar date or hour-start instant in the user's time zone."));
properties.put("value", new Schema().withType(Type.NUMBER).withNullable(true));
properties.put("categories", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)));
properties.put("drill", new Schema().withRef("#/components/schemas/DataAnalysisDrillV1").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** Unevaluated means no state change; baseline bounds describe the annotation.
*******************************************************************************/
public static class DataAnalysisWatchVerdictV1 implements ToSchema
{
public Boolean evaluated;
public Boolean firing;
public Double value;
public Double baseline;
public Double baselineLow;
public Double baselineHigh;
public String message;
public DataAnalysisSchemas.DataAnalysisDrillV1 drill;
/*******************************************************************************
** Carry numeric baseline bounds as well as the plain-English explanation.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("evaluated", new Schema().withType(Type.BOOLEAN).withDescription("False for insufficient history or privacy suppression; must not resolve an alert."));
properties.put("firing", new Schema().withType(Type.BOOLEAN));
for(String field : List.of("value", "baseline", "baselineLow", "baselineHigh"))
{
properties.put(field, new Schema().withType(Type.NUMBER).withNullable(true));
}
properties.put("message", new Schema().withType(Type.STRING));
properties.put("drill", new Schema().withRef("#/components/schemas/DataAnalysisDrillV1").withNullable(true));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
/*******************************************************************************
** Complete preview, using the same semantic chart as other analysis endpoints.
*******************************************************************************/
public static class DataAnalysisWatchPreviewV1 implements ToSchema
{
public List<DataAnalysisWatchBucketV1> series;
public DataAnalysisSchemas.DataAnalysisChartV1 chart;
public DataAnalysisWatchVerdictV1 verdict;
/*******************************************************************************
** Stable references let clients vendor this fragment without middleware.
*******************************************************************************/
@Override
public Schema toSchema()
{
Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("series", new Schema().withType(Type.ARRAY).withItems(new Schema().withRef("#/components/schemas/DataAnalysisWatchBucketV1")));
properties.put("chart", new Schema().withRef("#/components/schemas/DataAnalysisChartV1").withDescription("TIME_SERIES with baseline band annotations."));
properties.put("verdict", new Schema().withRef("#/components/schemas/DataAnalysisWatchVerdictV1"));
return (new Schema().withType(Type.OBJECT).withProperties(properties));
}
}
}
@@ -0,0 +1,355 @@
/*
* 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.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
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;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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;
/*******************************************************************************
** Standard QQQ statistics and narrow table widgets, all queried as the viewer.
*******************************************************************************/
public class DataAnalysisHome extends AbstractWidgetRenderer
{
public static final String SETUP = "dataAnalysisSetup";
public static final String HOME_WIDGET = "dataAnalysisHome";
public static final String STARRED = "dataAnalysisStarred";
public static final String FIRING = "dataAnalysisFiring";
public static final String RECENT = "dataAnalysisRecent";
/*******************************************************************************
** The existing home name now supplies standard multi-statistics data.
*******************************************************************************/
public static QWidgetMetaData widget()
{
return (widget(HOME_WIDGET, "Analysis overview", WidgetType.MULTI_STATISTICS));
}
/*******************************************************************************
** Full-width tables have three or four columns and honest empty states.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(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)));
}
/*******************************************************************************
** The widget itself grants no privilege; each data section checks table READ.
*******************************************************************************/
private static QWidgetMetaData widget(String name, String label, WidgetType type)
{
return (new QWidgetMetaData().withName(name).withLabel(label).withType(type.getType()).withGridColumns(12).withIsCard(true)
.withShowReloadButton(true).withCodeReference(new QCodeReference(DataAnalysisHome.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)));
}
/*******************************************************************************
** Main tables remain navigation; schedules and stars remain child lists.
*******************************************************************************/
public static QAppMetaData app(DataAnalysisQBitConfig config, List<QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel())
.withIcon(new QIcon("insights")).withWidgets(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());
for(String name : DataAnalysisTables.NAV_TABLES)
{
tables.stream().filter(table -> name.equals(table.getName())).findFirst().ifPresent(app::withChild);
}
return (app);
}
/*******************************************************************************
** Dispatch data by registered metadata name, never by an arbitrary table input.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(render(input.getWidgetMetaData().getName(), Instant.now())));
}
/*******************************************************************************
** Counts and table rows share core-enforced permissions and record locks.
*******************************************************************************/
public static QWidgetData render(String name, Instant now) throws QException
{
if(SETUP.equals(name))
{
return (setup());
}
if(HOME_WIDGET.equals(name))
{
return (statistics(now));
}
if(STARRED.equals(name))
{
TableData data = table("Starred analyses", DataAnalysisTables.SAVED, "No starred analyses yet. Star a saved analysis to see it here.", "Name", "Table", "Kind");
if(canRead(DataAnalysisTables.SAVED))
{
for(var entry : DataAnalysisRuntime.saved().list().stream().filter(SavedAnalyses.Entry::starred).toList())
{
data.getRows().add(values(link(DataAnalysisTables.SAVED, entry.id(), entry.name()), entry.tableLabel(), entry.kind()));
}
}
return (data);
}
String source = FIRING.equals(name) ? DataAnalysisTables.WATCH : DataAnalysisTables.RUN;
TableData data = FIRING.equals(name)
? table("Firing watches", source, "No firing watches.", "Watch", "Table", "Last evaluated", "Message")
: table("My recent runs", source, "No analysis runs yet.", "Analysis", "Table", "Status", "Started");
if(!canRead(source))
{
return (data);
}
QQueryFilter filter = FIRING.equals(name) ? equal("state", "FIRING")
: equal("user_id", SavedAnalyses.user()).withCriteria("started_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now);
filter.withOrderBy(new QFilterOrderBy(FIRING.equals(name) ? "last_evaluated_at" : "started_at", false)).withLimit(10);
for(var row : SavedAnalyses.query(source, filter))
{
String tableName = row.getValueString("table_name");
var metadata = QContext.getQInstance().getTable(tableName);
String label = metadata == null ? Objects.toString(tableName, "Saved analysis") : metadata.getLabel();
data.getRows().add(FIRING.equals(name)
? values(link(source, row.getValue("id"), row.getValueString("detector")), label, row.getValue("last_evaluated_at"), row.getValue("last_message"))
: values(link(source, row.getValue("id"), row.getValueString("operation")), label, row.getValue("status"), row.getValue("started_at")));
}
return (data);
}
/*******************************************************************************
** 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<>();
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);
}
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);
}
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);
}
return (new MultiStatisticsData("Analyses", groups));
}
/*******************************************************************************
** A group is only built for a readable subject, so its subheader is always true.
*******************************************************************************/
private static MultiStatisticsData.StatisticsGroupData group(String label)
{
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of()).withSubheader("Visible to you"));
}
/*******************************************************************************
** Link every count to its actual table path.
*******************************************************************************/
private static MultiStatisticsData.StatisticsGroupData.Statistic stat(String label, int count, String table) throws QException
{
return (new MultiStatisticsData.StatisticsGroupData.Statistic(label, count, QContext.getQInstance().getTablePath(table)));
}
/*******************************************************************************
** Standard table cells support escaped record links; other columns are plain text.
*******************************************************************************/
private static TableData table(String label, String table, String empty, String... headers) throws QException
{
List<TableData.Column> columns = new ArrayList<>();
for(int i = 0; i < headers.length; i++)
{
columns.add(new TableData.Column(i == 0 ? "html" : "text", headers[i], "c" + i, null, "left"));
}
return (new TableData(label, columns, new ArrayList<>()).withNoRowsFoundHTML(empty)
.withLinkText("View all").withLinkURL(QContext.getQInstance().getTablePath(table)).withRowsPerPage(10));
}
/*******************************************************************************
** Preserve real values while avoiding null map entries and executable HTML.
*******************************************************************************/
private static Map<String, Object> values(Object... values)
{
Map<String, Object> row = new LinkedHashMap<>();
for(int i = 0; i < values.length; i++)
{
row.put("c" + i, Objects.toString(values[i], ""));
}
return (row);
}
/*******************************************************************************
** Standard table HTML cell contains only an escaped link and escaped text.
*******************************************************************************/
private static String link(String table, Object id, String label) throws QException
{
return ("<a href=\"" + escape(QContext.getQInstance().getTablePath(table) + "/" + id) + "\">" + escape(label) + "</a>");
}
/*******************************************************************************
** Metadata labels and stored names are never trusted markup.
*******************************************************************************/
private static String escape(String value)
{
return (Objects.toString(value, "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;"));
}
/*******************************************************************************
** Explicit checks distinguish denied sections from empty sections.
*******************************************************************************/
static boolean canRead(String table)
{
CountInput input = new CountInput(table);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (PermissionsHelper.hasTablePermission(input, table, TablePermissionSubType.READ));
}
/*******************************************************************************
** Simple filter; record security is applied exclusively by core.
*******************************************************************************/
private static QQueryFilter equal(String field, java.io.Serializable value)
{
return (DrillFilters.condition(field, QCriteriaOperator.EQUALS, value));
}
/*******************************************************************************
** Counts use the viewer's session and explicitly enforce permissions.
*******************************************************************************/
static int count(String table, QQueryFilter filter) throws QException
{
CountInput input = new CountInput(table).withFilter(filter);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new CountAction().execute(input).getCount());
}
}
@@ -0,0 +1,173 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.TableConfigDefaultsProcess;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** The Setup card on the Analyses home: each configuration or content table,
** whether it is set up, and the next step. Reads settings through the same
** resolver the analysis runtime uses (code defaults, then rows), so the card
** never reports something the runtime would not do. Counts run as the viewer.
*******************************************************************************/
public final class DataAnalysisSetupStatus
{
/*******************************************************************************
** OK: set up. DEFAULT: working on built-in defaults (nothing required).
** OPTIONAL: a feature that stays off until someone uses it. UNAVAILABLE: the
** viewer cannot read the table.
*******************************************************************************/
public enum State
{
OK, DEFAULT, OPTIONAL, UNAVAILABLE, UNKNOWN
}
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisSetupStatus()
{
}
/*******************************************************************************
** The card's lines, in the order a person would set things up.
*******************************************************************************/
public static List<Item> load() throws QException
{
DataAnalysisQBitConfig config = DataAnalysisRuntime.service().getConfigResolver().getDefaults();
List<Item> items = new ArrayList<>();
///////////////////////
// analysis settings //
///////////////////////
List<String> analyzable = TableConfigDefaultsProcess.analyzableTables(QContext.getQInstance(), config);
if(!DataAnalysisHome.canRead(DataAnalysisTables.TABLE_CONFIG))
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
}
else
{
int rows = DataAnalysisHome.count(DataAnalysisTables.TABLE_CONFIG, new QQueryFilter());
String defaults = "max rows " + config.getDefaultMaxRows() + ", sample " + config.getDefaultSampleRows() + " above " + config.getDefaultSampleThresholdRows()
+ ", smallest group " + config.getDefaultMinGroupSize() + ", results reused " + config.getDefaultCacheTtlMinutes() + " min";
if(rows == 0)
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, State.DEFAULT,
"All " + analyzable.size() + " analyzable tables are on, with the defaults (" + defaults + ").",
"Run Create rows for analyzable tables to see and edit them, or add a row to change one table."));
}
else
{
items.add(new Item("Analysis settings", DataAnalysisTables.TABLE_CONFIG, rows >= analyzable.size() ? State.OK : State.DEFAULT,
rows + " settings row" + (rows == 1 ? "" : "s") + " for " + analyzable.size() + " analyzable tables; tables without a row use the defaults (" + defaults + ").",
rows >= analyzable.size() ? null : "Run Create rows for analyzable tables to add rows for the rest."));
}
}
/////////////////////////////////////////////////////////////////////
// result cache: the resolved value, exactly what caching will use //
/////////////////////////////////////////////////////////////////////
items.add(cacheItem(analyzable, config));
///////////////////////////////////////////////////
// saved analyses, watches, schedules: optional //
///////////////////////////////////////////////////
items.add(optional("Saved analyses", DataAnalysisTables.SAVED, "No saved analyses yet.", "Save an analysis from the Analyze screen, then star or share it."));
String scheduler = config.getSchedulerName() == null ? " No scheduler is configured, so watches only run with Evaluate now." : "";
items.add(optional("Watches", DataAnalysisTables.WATCH, "No watches yet." + scheduler, "Add a watch on a saved analysis to be alerted to spikes, drops and new categories."));
String schedules = config.getSchedulerName() == null ? " No scheduler is configured, so schedules do not run." : "";
items.add(optional("Schedules", DataAnalysisTables.SCHEDULE, "No schedules yet." + schedules, "Add a schedule on a saved analysis to send it by email."));
return (items);
}
/*******************************************************************************
** One optional feature: set up once it has rows.
*******************************************************************************/
private static Item optional(String label, String table, String emptyDetail, String nextStep) throws QException
{
if(!DataAnalysisHome.canRead(table))
{
return (new Item(label, table, State.UNAVAILABLE, "Unavailable: READ permission required.", null));
}
int rows = DataAnalysisHome.count(table, new QQueryFilter());
return (rows == 0 ? new Item(label, table, State.OPTIONAL, emptyDetail, nextStep) : new Item(label, table, State.OK, rows + " in use.", null));
}
/*******************************************************************************
** The Result cache line, from the minutes every analyzable table actually
** resolves to (settings row, then table defaults, then the global default).
*******************************************************************************/
private static Item cacheItem(List<String> analyzable, DataAnalysisQBitConfig config)
{
java.util.TreeMap<Integer, Integer> tablesPerMinutes = new java.util.TreeMap<>();
for(String table : analyzable)
{
try
{
tablesPerMinutes.merge(DataAnalysisRuntime.service().getConfigResolver().resolve(table).getCacheTtlMinutes(), 1, Integer::sum);
}
catch(QException e)
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.UNKNOWN, "The cache settings of " + table + " could not be read: " + e.getMessage(),
"Fix the settings row for that table (a negative limit is invalid)."));
}
}
if(tablesPerMinutes.isEmpty() || (tablesPerMinutes.size() == 1 && tablesPerMinutes.firstKey() == 0))
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.DEFAULT, "Off: every exact run computes fresh.", "Set cache minutes on Analysis Settings to reuse results."));
}
if(tablesPerMinutes.size() == 1)
{
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused for " + tablesPerMinutes.firstKey() + " minutes.", null));
}
String parts = tablesPerMinutes.entrySet().stream().map(e -> e.getValue() + (e.getValue() == 1 ? " table" : " tables") + (e.getKey() == 0 ? ": off" : ": " + e.getKey() + " min"))
.collect(java.util.stream.Collectors.joining(", "));
return (new Item("Result cache", DataAnalysisTables.RUN, State.OK, "Exact results are reused per table (default " + config.getDefaultCacheTtlMinutes() + " min): " + parts + ".", null));
}
}
@@ -0,0 +1,157 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.app;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.result.Chart;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.saved.AnalysisExport;
import com.kingsrook.qbits.dataanalysis.watch.WatchRecords;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartSubheaderData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
/*******************************************************************************
** Saved analysis rendered through Next's existing standard chart widget.
*******************************************************************************/
public class SavedAnalysisWidget extends AbstractWidgetRenderer
{
public static final String NAME = "DATA_ANALYSIS_SAVED";
/*******************************************************************************
** Hosts clone this metadata and set savedId to place an analysis on a dashboard.
*******************************************************************************/
public static QWidgetMetaData widget()
{
return (new QWidgetMetaData().withName(NAME).withLabel("Saved analysis").withType(WidgetType.BAR_CHART.getType())
.withGridColumns(12).withIsCard(true).withCodeReference(new QCodeReference(SavedAnalysisWidget.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)));
}
/*******************************************************************************
** Values come from a fresh viewer run; no stored owner result is ever reused.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
var defaults = ((QWidgetMetaData) input.getWidgetMetaData()).getDefaultValues();
Object value = defaults == null ? null : defaults.get("savedId");
if(value == null)
{
return (new RenderWidgetOutput(new ChartData("Saved analysis", "Choose a saved analysis for this widget.", "Analysis", List.of(), List.of())));
}
Integer id;
try
{
id = Integer.valueOf(value.toString());
}
catch(NumberFormatException e)
{
throw new QBadRequestException("savedId must be an integer.");
}
var saved = WatchRecords.get(DataAnalysisTables.SAVED, id);
var result = DataAnalysisRuntime.saved().run(id);
List<Metric> metrics = AnalysisExport.headlines(result);
Chart chart = null;
if(result.columnProfile() != null)
{
chart = result.columnProfile().getChart();
}
else if(result.tableProfile() != null)
{
chart = result.tableProfile().getHeatmap();
}
else if(result.correlation() != null)
{
chart = result.correlation().getChart();
}
else if(result.shift() != null)
{
chart = result.shift().getChart();
}
else if(result.compare() != null && !result.compare().getFields().isEmpty())
{
chart = result.compare().getFields().getFirst().getChart();
}
String description = metrics.stream().filter(Objects::nonNull).map(m -> m.getLabel() + ": " + AnalysisExport.value(m))
.collect(java.util.stream.Collectors.joining("; "));
if(!result.warnings().isEmpty())
{
description += " " + String.join("; ", result.warnings());
}
ChartData data = new ChartData().withTitle(saved.getValueString("name")).withDescription(description).withHeight(280);
String path = QContext.getQInstance().getTablePath(DataAnalysisTables.SAVED) + "/" + id;
Number mainNumber = metrics.stream().filter(m -> !Boolean.TRUE.equals(m.getSuppressed()) && m.getValue() instanceof Number)
.map(m -> (Number) m.getValue()).findFirst().orElse(null);
data.withChartSubheaderData(new ChartSubheaderData().withMainNumber(mainNumber).withMainNumberUrl(path));
List<String> labels = new ArrayList<>();
List<ChartData.Data.Dataset> datasets = new ArrayList<>();
if(chart != null && !chart.getSeries().isEmpty())
{
chart.getSeries().getFirst().getPoints().forEach(p -> labels.add(Objects.toString(p.getLabel(), Objects.toString(p.getX(), ""))));
for(var series : chart.getSeries())
{
datasets.add(new ChartData.Data.Dataset().withLabel(series.getName())
.withData(series.getPoints().stream().map(p -> (Number) p.getY()).toList()));
}
}
else if(result.pivot() != null)
{
List<Number> numbers = new ArrayList<>();
for(var cell : result.pivot().getCells())
{
labels.add(java.util.stream.Stream.concat(result.pivot().getRowHeaders().get(cell.getRowIndex()).stream(),
result.pivot().getColumnHeaders().get(cell.getColumnIndex()).stream()).map(p -> p.getLabel()).collect(java.util.stream.Collectors.joining(" / ")));
Metric metric = cell.getValues().getFirst();
numbers.add(!Boolean.TRUE.equals(metric.getSuppressed()) && metric.getValue() instanceof Number n ? n : null);
}
datasets.add(new ChartData.Data.Dataset().withLabel(result.pivot().getMeasures().getFirst().getLabel()).withData(numbers));
}
else if(result.crosstab() != null)
{
List<Number> numbers = new ArrayList<>();
for(var cell : result.crosstab().getCells())
{
labels.add(result.crosstab().getRows().get(cell.getRowIndex()).getLabel() + " / " + result.crosstab().getColumns().get(cell.getColumnIndex()).getLabel());
Metric metric = cell.getCount();
numbers.add(!Boolean.TRUE.equals(metric.getSuppressed()) && metric.getValue() instanceof Number n ? n : null);
}
datasets.add(new ChartData.Data.Dataset().withLabel("Count").withData(numbers));
}
data.withChartData(new ChartData.Data().withLabels(labels).withUrls(labels.stream().map(label -> path).toList()).withDatasets(datasets));
data.withFooterHTML("<a href=\"" + path.replace("&", "&amp;").replace("\"", "&quot;") + "\">View saved analysis</a>");
return (new RenderWidgetOutput(data));
}
}
@@ -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);
}
}
@@ -0,0 +1,186 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Primitive port. All operations use the current session with ENFORCE.
*******************************************************************************/
public interface AnalysisBackend
{
/*******************************************************************************
** Date units use the session zone and ISO Monday weeks.
*******************************************************************************/
enum DateUnit
{
DAY, WEEK, MONTH, QUARTER, YEAR, HOUR, DAY_OF_WEEK, HOUR_OF_DAY
}
/*******************************************************************************
** Computation scope, observed coverage and optional native sampling percentage.
*******************************************************************************/
record Scope(QQueryFilter filter, long total, long rowsScanned, boolean truncated, boolean estimate, BigDecimal samplePercent)
{
/*******************************************************************************
** Exact and prefix scopes have no random sampling percentage.
*******************************************************************************/
public Scope(QQueryFilter filter, long total, long rowsScanned, boolean truncated, boolean estimate)
{
this(filter, total, rowsScanned, truncated, estimate, null);
}
/*******************************************************************************
** Explain whether counts extrapolate a random sample or describe a prefix.
*******************************************************************************/
public String warning()
{
return (samplePercent == null ? "Estimate from the first " + rowsScanned + " rows by id"
: "Estimate from a random sample of about " + rowsScanned + " rows (" + samplePercent.stripTrailingZeros().toPlainString()
+ "%). Counts are scaled to the full " + total + " rows; run exact for exact numbers.");
}
}
/*******************************************************************************
** Blank includes null and whitespace; distinct excludes null.
*******************************************************************************/
record FieldCounts(long nonNull, long nulls, long blanks, long distinct, QQueryFilter blankFilter, QQueryFilter distinctFilter, BigDecimal uniqueness, BigDecimal completeness, boolean constant)
{
/*******************************************************************************
** Exact and prefix counters use their own population for ratios.
*******************************************************************************/
public FieldCounts(long nonNull, long nulls, long blanks, long distinct, QQueryFilter blankFilter, QQueryFilter distinctFilter)
{
this(nonNull, nulls, blanks, distinct, blankFilter, distinctFilter, ProfileSupport.share(distinct, nonNull),
ProfileSupport.share(nonNull + nulls - blanks, nonNull + nulls), distinct == 1);
}
}
/*******************************************************************************
** Group and exact predicate producing its members.
*******************************************************************************/
record GroupCount(Serializable value, String label, long count, QQueryFilter condition, BigDecimal share)
{
/*******************************************************************************
** Ordinary groups derive their share from the profiler population.
*******************************************************************************/
public GroupCount(Serializable value, String label, long count, QQueryFilter condition)
{
this(value, label, count, condition, null);
}
}
/*******************************************************************************
** Numeric moments, percentile_cont and sample standard deviation.
*******************************************************************************/
record NumericSummary(Map<String, BigDecimal> metrics, List<GroupCount> outliers, long outlierCount, QQueryFilter outlierFilter, List<GroupCount> counts)
{
}
/*******************************************************************************
** Text lengths exclude null but include empty strings.
*******************************************************************************/
record LengthSummary(Map<String, BigDecimal> metrics, List<GroupCount> histogram)
{
}
/*******************************************************************************
** Actual inspected non-null values, independent of estimated population counts.
*******************************************************************************/
record PatternSummary(List<GroupCount> groups, long values, boolean truncated)
{
}
/*******************************************************************************
** Filtered count; a native estimate scales conditional counts to its population.
*******************************************************************************/
long count(String table, QQueryFilter filter) throws QException;
/*******************************************************************************
** Configure a request-local exact, prefix or native sampling scope.
*******************************************************************************/
Scope scope(String table, QQueryFilter filter, boolean exact) throws QException;
/*******************************************************************************
** Batch field counts in groups of twenty.
*******************************************************************************/
Map<String, FieldCounts> fieldCounts(String table, QQueryFilter filter, List<String> fields) throws QException;
/*******************************************************************************
** Ordered groups with search on the stored value.
*******************************************************************************/
List<GroupCount> topValues(String table, QQueryFilter filter, String field, int limit, int offset, String search) throws QException;
/*******************************************************************************
** Numeric summary and ten extreme-value groups.
*******************************************************************************/
NumericSummary numericSummary(String table, QQueryFilter filter, String field, List<BigDecimal> percentiles) throws QException;
/*******************************************************************************
** Half-open interior bins, closed final bin, and exterior bins.
*******************************************************************************/
List<GroupCount> numericHistogram(String table, QQueryFilter filter, String field, BigDecimal low, BigDecimal high, int bins) throws QException;
/*******************************************************************************
** Date buckets include interior zero intervals.
*******************************************************************************/
List<GroupCount> dateBuckets(String table, QQueryFilter filter, String field, DateUnit unit, ZoneId zone) throws QException;
/*******************************************************************************
** Typed minimum and maximum.
*******************************************************************************/
Map<String, Serializable> extrema(String table, QQueryFilter filter, String field) throws QException;
/*******************************************************************************
** Length moments and histogram.
*******************************************************************************/
LengthSummary lengthSummary(String table, QQueryFilter filter, String field) throws QException;
/*******************************************************************************
** Top shapes over a bounded primary-key prefix.
*******************************************************************************/
PatternSummary patterns(String table, QQueryFilter filter, String field, int sampleRows) throws QException;
/*******************************************************************************
** Top groups with count greater than one.
*******************************************************************************/
List<GroupCount> duplicates(String table, QQueryFilter filter, List<String> fields, int limit) throws QException;
}
@@ -0,0 +1,168 @@
/*
* 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.engine;
import java.util.Set;
import com.kingsrook.qbits.dataanalysis.result.CandidateKey;
import com.kingsrook.qbits.dataanalysis.result.Chart;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
import com.kingsrook.qbits.dataanalysis.result.DateProfile;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesPage;
import com.kingsrook.qbits.dataanalysis.result.Drill;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesResult;
import com.kingsrook.qbits.dataanalysis.result.FieldSummary;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.NumericProfile;
import com.kingsrook.qbits.dataanalysis.result.Point;
import com.kingsrook.qbits.dataanalysis.result.Series;
import com.kingsrook.qbits.dataanalysis.result.TableProfile;
import com.kingsrook.qbits.dataanalysis.result.TextProfile;
import com.kingsrook.qbits.dataanalysis.result.ValueCount;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Final defense for profile-generated drills, including representative primary
** keys and prefix bounds. User-authored predicates remain intact; unsafe generated
** drills are disabled rather than broadened by removing their conditions.
*******************************************************************************/
public class AnalysisDrillGuard
{
private final Set<String> fields;
private final String original;
/*******************************************************************************
** Resolve FULL fields once per completed result.
*******************************************************************************/
public AnalysisDrillGuard(Set<String> fields, QQueryFilter original)
{
this.fields = fields;
this.original = JsonUtils.toJson(DrillFilters.copy(original));
}
/*******************************************************************************
** Visit only typed result containers, never the caller's query.
*******************************************************************************/
public void apply(Object value)
{
if(value instanceof Iterable<?> list)
{
list.forEach(this::apply);
}
else if(value instanceof Metric metric)
{
metric.withDrill(permitted(metric.getDrill()));
}
else if(value instanceof ValueCount count)
{
count.withDrill(permitted(count.getDrill()));
}
else if(value instanceof Point point)
{
point.withDrill(permitted(point.getDrill()));
}
else if(value instanceof CandidateKey key)
{
key.withDrill(permitted(key.getDrill()));
}
else if(value instanceof ColumnProfile profile)
{
apply(profile.getCounts());
apply(profile.getTopValues());
apply(profile.getBooleans());
apply(profile.getNumeric());
apply(profile.getDates());
apply(profile.getText());
apply(profile.getChart());
}
else if(value instanceof NumericProfile profile)
{
apply(profile.getMetrics());
apply(profile.getHistogram());
apply(profile.getOutliers());
}
else if(value instanceof DateProfile profile)
{
apply(profile.getMetrics());
apply(profile.getBuckets());
apply(profile.getGaps());
apply(profile.getDayOfWeek());
apply(profile.getHourOfDay());
}
else if(value instanceof TextProfile profile)
{
apply(profile.getMetrics());
apply(profile.getHistogram());
apply(profile.getPatterns());
}
else if(value instanceof TableProfile profile)
{
apply(profile.getRowCount());
apply(profile.getFields());
apply(profile.getCandidateKeys());
apply(profile.getHeatmap());
}
else if(value instanceof FieldSummary field)
{
apply(field.getNulls());
apply(field.getBlanks());
}
else if(value instanceof Chart chart)
{
apply(chart.getSeries());
}
else if(value instanceof Series series)
{
apply(series.getPoints());
}
else if(value instanceof DistinctValuesPage page)
{
apply(page.getValues());
}
else if(value instanceof DuplicatesResult result)
{
apply(result.getGroups());
}
}
/*******************************************************************************
** Preserve count equivalence by removing the entire unsafe drill.
*******************************************************************************/
private Drill permitted(Drill drill)
{
return (drill == null || safe(drill.getFilter()) ? drill : null);
}
/*******************************************************************************
** An exact copy of a user subtree is exempt from analysis field restrictions.
*******************************************************************************/
private boolean safe(QQueryFilter filter)
{
if(original.equals(JsonUtils.toJson(DrillFilters.copy(filter))))
{
return (true);
}
return (filter.getCriteria().stream().allMatch(c -> fields.contains(c.getFieldName())
&& (c.getOtherFieldName() == null || fields.contains(c.getOtherFieldName()))
&& (c.getFieldFunction() == null || fields.contains(c.getFieldFunction().getFieldName())))
&& filter.getSubFilters().stream().allMatch(this::safe));
}
}
@@ -0,0 +1,225 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
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.utils.ValueUtils;
/*******************************************************************************
** Display-ready values shared by metrics, chart labels and the home widget.
*******************************************************************************/
public final class AnalysisFormatting
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AnalysisFormatting()
{
}
/*******************************************************************************
** Ratios remain numeric and bounded, with stable JSON precision.
*******************************************************************************/
public static BigDecimal ratio(BigDecimal value)
{
return (value.max(BigDecimal.ZERO).min(BigDecimal.ONE).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros());
}
/*******************************************************************************
** Plain numeric defaults are independent of the server locale.
*******************************************************************************/
public static String number(Number value, int decimals)
{
return (String.format(Locale.US, "%,." + decimals + "f", new BigDecimal(value.toString())));
}
/*******************************************************************************
** Types and length keys determine display without changing numeric raw values.
*******************************************************************************/
public static String scalar(String key, Serializable value, ValueType type)
{
if(value == null)
{
return (null);
}
if(type == ValueType.PERCENT)
{
BigDecimal percent = new BigDecimal(value.toString()).multiply(BigDecimal.valueOf(100)).setScale(1, RoundingMode.HALF_UP);
return (percent.signum() == 0 || percent.compareTo(BigDecimal.valueOf(100)) == 0 ? percent.toBigInteger() + "%" : percent.toPlainString() + "%");
}
if(type == ValueType.DATE_TIME)
{
return (instant((Instant) value));
}
if(value instanceof Number n)
{
return (number(n, "meanLength".equals(key) ? 1 : type == ValueType.COUNT || key.endsWith("Length") ? 0 : 2));
}
return (value.toString());
}
/*******************************************************************************
** Display formats belong to the host field; fallback defaults preserve fractions.
*******************************************************************************/
public static String numeric(QFieldMetaData field, String key, BigDecimal value)
{
if(value == null)
{
return (null);
}
String format = field.getDisplayFormat();
if(format != null && !format.isBlank() && !DisplayFormat.DEFAULT.equals(format))
{
Serializable displayValue = format.endsWith("d") ? value.toBigInteger() : value;
return (QValueFormatter.formatValue(format, displayValue));
}
return (number(value, field.getType() == QFieldType.DECIMAL || "mean".equals(key) || "stddev".equals(key) ? 2 : 0));
}
/*******************************************************************************
** Decimal statistics retain ten significant digits; formatting uses the original.
*******************************************************************************/
public static Metric numericMetric(QFieldMetaData field, String key, BigDecimal value)
{
BigDecimal raw = value == null || field.getType() != QFieldType.DECIMAL ? value : value.round(new MathContext(10));
return (ProfileSupport.metric(key, raw, ValueType.DECIMAL).withFormatted(numeric(field, key, value)));
}
/*******************************************************************************
** Use the same field formatter on both ends of every histogram interval.
*******************************************************************************/
public static String interval(QFieldMetaData field, BigDecimal low, BigDecimal high)
{
return (low == null ? "Below " + edge(field, high)
: high == null ? edge(field, low) + " and above"
: edge(field, low) + " – " + edge(field, high));
}
/*******************************************************************************
** Whole edges omit redundant cents; unformatted fractional edges stay distinct.
*******************************************************************************/
private static String edge(QFieldMetaData field, BigDecimal value)
{
String format = field.getDisplayFormat();
int scale = Math.max(0, value.stripTrailingZeros().scale());
if(format == null || format.isBlank() || DisplayFormat.DEFAULT.equals(format))
{
return (number(value, scale));
}
String formatted = numeric(field, "min", value);
return (scale == 0 && DisplayFormat.CURRENCY.equals(format) && formatted.endsWith(".00")
? formatted.substring(0, formatted.length() - 3) : formatted);
}
/*******************************************************************************
** Instants remain UTC in raw values and display in the current session zone.
*******************************************************************************/
public static String instant(Instant value)
{
return (DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm", Locale.US).format(value.atZone(ValueUtils.getSessionOrInstanceZoneId())));
}
/*******************************************************************************
** Calendar duration avoids misleading fixed-length month and year arithmetic.
*******************************************************************************/
public static String duration(LocalDate first, LocalDate last)
{
Period period = Period.between(first, last);
List<String> parts = new ArrayList<>();
if(period.getYears() != 0)
{
parts.add(period.getYears() + (period.getYears() == 1 ? " year" : " years"));
}
if(period.getMonths() != 0)
{
parts.add(period.getMonths() + (period.getMonths() == 1 ? " month" : " months"));
}
if(period.getDays() != 0 || parts.isEmpty())
{
parts.add(period.getDays() + (period.getDays() == 1 ? " day" : " days"));
}
return (String.join(" ", parts));
}
/*******************************************************************************
** Calendar labels describe one bucket; the raw value remains its ISO start.
*******************************************************************************/
public static String dateBucket(Serializable start, AnalysisBackend.DateUnit unit, ZoneId zone)
{
if(unit == AnalysisBackend.DateUnit.DAY_OF_WEEK)
{
return (DayOfWeek.of(((Number) start).intValue()).getDisplayName(TextStyle.FULL, Locale.US));
}
if(unit == AnalysisBackend.DateUnit.HOUR_OF_DAY)
{
return (String.format(Locale.US, "%02d:00", ((Number) start).intValue()));
}
LocalDate date = ProfileSupport.day(start, zone);
return (switch(unit)
{
case YEAR -> Integer.toString(date.getYear());
case QUARTER -> date.getYear() + " Q" + ((date.getMonthValue() - 1) / 3 + 1);
case MONTH -> DateTimeFormatter.ofPattern("MMM uuuu", Locale.US).format(date);
case WEEK -> "Week of " + date;
case HOUR -> DateTimeFormatter.ofPattern("uuuu-MM-dd HH:00", Locale.US).format(((Instant) start).atZone(zone));
default -> date.toString();
});
}
}
@@ -0,0 +1,199 @@
/*
* 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.engine;
import java.io.Serializable;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.values.QPossibleValueTranslator;
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Translate through core while enforcing access to every referenced label.
** RC.5's direct table-PVS translation bypasses permissions, so table values are
** read with ENFORCE and handed to the translator as an ephemeral enum source.
** No shared metadata or session is mutated.
*******************************************************************************/
public final class AnalysisLabels
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AnalysisLabels()
{
}
/*******************************************************************************
** Missing or denied labels fall back to the already-readable source id.
*******************************************************************************/
public static String translate(QFieldMetaData field, Serializable value) throws QException
{
return (translate(field, value, CoreTierFieldAccessResolver.defaultResolver()));
}
/*******************************************************************************
** Resolve nested PVS label fields without invoking core's trusted lookup path.
*******************************************************************************/
public static String translate(QFieldMetaData field, Serializable value, FieldAnalysisAccessResolver access) throws QException
{
return (translate(field, value, new LinkedHashSet<>(), access));
}
/*******************************************************************************
** Resolve nested labels only through FULL fields under the host policy.
*******************************************************************************/
private static String translate(QFieldMetaData field, Serializable value, Set<String> visited, FieldAnalysisAccessResolver access) throws QException
{
if(value == null)
{
return (null);
}
if(field.getType() == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.BOOLEAN)
{
return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsBoolean(value) ? "Yes" : "No");
}
String fallback = value.toString();
QPossibleValueSource source = QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName());
if(source == null || visited.size() >= 8 || !visited.add(source.getName()))
{
return (fallback);
}
try
{
QPossibleValueTranslator translator = new QPossibleValueTranslator();
if(source.getType() != QPossibleValueSourceType.TABLE)
{
return (translator.translatePossibleValue(field, value));
}
QueryInput input = new QueryInput(source.getTableName());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QTableMetaData table = TableMetaDataPersonalizerAction.execute(input, true);
QTableMetaData original = QContext.getQInstance().getTable(source.getTableName());
List<String> labelFields = original.getRecordLabelFields() == null ? List.of() : original.getRecordLabelFields();
for(String labelField : labelFields)
{
if(!table.getFields().containsKey(labelField) || access.resolve(table, table.getField(labelField)) != FieldAnalysisAccess.FULL)
{
return (fallback);
}
}
String id = source.getOverrideIdField() == null ? table.getPrimaryKeyField() : source.getOverrideIdField();
Set<String> selection = new LinkedHashSet<>(labelFields);
selection.add(id);
input.withFilter(DrillFilters.condition(id, QCriteriaOperator.EQUALS, value).withLimit(1)).withFieldNamesToInclude(selection)
.withShouldFetchHeavyFields(true).withShouldGenerateDisplayValues(true);
List<QRecord> records = new QueryAction().execute(input).getRecords();
if(records.isEmpty())
{
return (fallback);
}
QRecord record = records.getFirst();
for(String labelField : labelFields)
{
QFieldMetaData labelMetadata = table.getField(labelField);
if(labelMetadata.getPossibleValueSourceName() != null)
{
record.withDisplayValue(labelField, translate(labelMetadata, record.getValue(labelField), visited, access));
}
}
String label = QValueFormatter.formatRecordLabel(table, record);
QPossibleValueSource permitted = new QPossibleValueSource().withName(source.getName()).withType(QPossibleValueSourceType.ENUM)
.withValueFormat(source.getValueFormat()).withValueFields(source.getValueFields()).withEnumValues(List.of(new QPossibleValue<>(value, label)));
return (translator.buildTranslatedPossibleValueList(permitted, List.of(value)).getFirst().getLabel());
}
catch(QPermissionDeniedException e)
{
return (fallback);
}
finally
{
visited.remove(source.getName());
}
}
/*******************************************************************************
** Cache identity includes personalized field and related-label policies, so a
** host changing field/record locks cannot release older translated labels.
*******************************************************************************/
public static java.util.Map<String, Object> policy(String tableName) throws QException
{
return (policy(tableName, CoreTierFieldAccessResolver.defaultResolver()));
}
/*******************************************************************************
** Include host-resolved tiers of related labels in the exact-run identity.
*******************************************************************************/
public static java.util.Map<String, Object> policy(String tableName, FieldAnalysisAccessResolver access) throws QException
{
java.util.Map<String, Object> result = new java.util.TreeMap<>();
java.util.ArrayDeque<String> pending = new java.util.ArrayDeque<>();
pending.add(tableName);
while(!pending.isEmpty())
{
String name = pending.removeFirst();
if(result.containsKey(name))
{
continue;
}
QueryInput input = new QueryInput(name);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QTableMetaData table = TableMetaDataPersonalizerAction.execute(input, true);
java.util.Map<String, Object> entry = new java.util.TreeMap<>();
entry.put("fields", table.getFields());
entry.put("fullFields", table.getFields().values().stream().filter(f -> access.resolve(table, f) == FieldAnalysisAccess.FULL).map(QFieldMetaData::getName).sorted().toList());
entry.put("locks", table.getRecordSecurityLocks());
entry.put("labelFields", table.getRecordLabelFields());
entry.put("labelFormat", table.getRecordLabelFormat());
entry.put("permissions", table.getPermissionRules());
result.put(name, entry);
for(QFieldMetaData field : table.getFields().values())
{
QPossibleValueSource source = QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName());
if(source != null && source.getType() == QPossibleValueSourceType.TABLE)
{
pending.add(source.getTableName());
}
}
}
return (result);
}
}
@@ -0,0 +1,150 @@
/*
* 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.engine;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerAction;
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.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Fail closed before any source-table computation, including cache retrieval.
*******************************************************************************/
public class AnalysisSecurity
{
private final EffectiveConfigResolver configs;
private final FieldAnalysisAccessResolver fieldAccess;
/*******************************************************************************
** Use the runtime's configuration policy.
*******************************************************************************/
public AnalysisSecurity(EffectiveConfigResolver configs)
{
this(configs, CoreTierFieldAccessResolver.defaultResolver());
}
/*******************************************************************************
** Bind the host field policy.
*******************************************************************************/
public AnalysisSecurity(EffectiveConfigResolver configs, FieldAnalysisAccessResolver fieldAccess)
{
this.configs = configs;
this.fieldAccess = fieldAccess;
}
/*******************************************************************************
** Explicit enforcement is independent of the instance's default switch.
*******************************************************************************/
public QTableMetaData table(String name) throws QException
{
QTableMetaData table = QContext.getQInstance().getTable(name);
QueryInput input = new QueryInput(name);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
if(table == null || !configs.resolve(name).getEnabled() || !DataAnalysisPermissions.canAnalyze(table)
|| !PermissionsHelper.hasTablePermission(input, name, TablePermissionSubType.READ))
{
throw new QPermissionDeniedException("Analysis is unavailable for this table.");
}
return (TableMetaDataPersonalizerAction.execute(input, true));
}
/*******************************************************************************
** Use personalized fields and locks; excluded fields never appear in capabilities.
*******************************************************************************/
public List<QFieldMetaData> analyzableFields(String name) throws QException
{
QTableMetaData table = table(name);
EffectiveConfig c = configs.resolve(name);
return (table.getFields().values().stream().filter(f -> fieldAccess.resolve(table, f) == FieldAnalysisAccess.FULL)
.filter(f -> c.getAnalyzableFields().isEmpty() || c.getAnalyzableFields().contains(f.getName()))
.filter(f -> !c.getExcludedFields().contains(f.getName())).toList());
}
/*******************************************************************************
** A common denial does not disclose a hidden field's existence or values.
*******************************************************************************/
public QFieldMetaData field(String table, String field) throws QException
{
return (analyzableFields(table).stream().filter(f -> f.getName().equals(field)).findFirst()
.orElseThrow(() -> new QBadRequestException("Unknown analysis field.")));
}
/*******************************************************************************
** Core fingerprint plus the sorted FULL-field list, hashed by the run identity.
*******************************************************************************/
public String accessFingerprint(String table) throws QException
{
return (fieldAccess.getAccessFingerprint() + ":" + JsonUtils.toJson(analyzableFields(table).stream().map(QFieldMetaData::getName).sorted().toList()));
}
/*******************************************************************************
** Check a joined field without requiring the related table analyze permission.
*******************************************************************************/
public QFieldMetaData joinedField(String tableName, String fieldName) throws QException
{
QTableMetaData table = joinedTable(tableName);
QFieldMetaData field = table.getFields().get(fieldName);
if(field == null || fieldAccess.resolve(table, field) != FieldAnalysisAccess.FULL)
{
throw new QBadRequestException("Unknown analysis field.");
}
return (field);
}
/*******************************************************************************
** Display labels use the same policy as the selected pivot field.
*******************************************************************************/
public String label(QFieldMetaData field, Serializable value) throws QException
{
return (AnalysisLabels.translate(field, value, fieldAccess));
}
/*******************************************************************************
** Each intermediate exposed-join table requires READ, without analyze rights.
*******************************************************************************/
public QTableMetaData joinedTable(String tableName) throws QException
{
QueryInput input = new QueryInput(tableName);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
if(QContext.getQInstance().getTable(tableName) == null || !PermissionsHelper.hasTablePermission(input, tableName, TablePermissionSubType.READ))
{
throw new QBadRequestException("Unknown analysis field.");
}
return (TableMetaDataPersonalizerAction.execute(input, true));
}
}
@@ -0,0 +1,259 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.DateProfile;
import com.kingsrook.qbits.dataanalysis.result.NumericProfile;
import com.kingsrook.qbits.dataanalysis.result.TextProfile;
import com.kingsrook.qbits.dataanalysis.result.ValueCount;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
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.utils.ValueUtils;
/*******************************************************************************
** Column profiles depend only on primitive backend operations and typed results.
*******************************************************************************/
public class ColumnProfiler
{
private final AnalysisBackend backend;
private final FieldAnalysisAccessResolver fieldAccess;
/*******************************************************************************
** The backend can be replaced when core's new operators ship.
*******************************************************************************/
public ColumnProfiler(AnalysisBackend backend)
{
this(backend, CoreTierFieldAccessResolver.defaultResolver());
}
/*******************************************************************************
** Bind the host field policy for possible-value labels.
*******************************************************************************/
public ColumnProfiler(AnalysisBackend backend, FieldAnalysisAccessResolver fieldAccess)
{
this.backend = backend;
this.fieldAccess = fieldAccess;
}
/*******************************************************************************
** Produce a guarded profile for a validated field and selected population.
*******************************************************************************/
public ColumnProfile profile(ColumnProfileRequest request, QFieldMetaData field, AnalysisBackend.Scope scope, EffectiveConfig config) throws QException
{
String table = request.getTableName();
String name = field.getName();
QQueryFilter filter = scope.filter();
long rows = backend.count(table, filter);
QQueryFilter drillFilter = scope.estimate() ? request.getFilter() : filter;
AnalysisBackend.FieldCounts counts = backend.fieldCounts(table, filter, List.of(name)).get(name);
ColumnProfile result = new ColumnProfile().withTableName(table).withFieldName(name).withFieldLabel(field.getLabel()).withFieldType(field.getType().name())
.withFilter(DrillFilters.restoreTypes(request.getTableName(), request.getFilter())).withEstimate(scope.estimate()).withSampleRows(scope.estimate() ? scope.rowsScanned() : 0L)
.withRowsScanned(scope.rowsScanned()).withTruncated(scope.truncated()).withAsOf(Instant.now());
result.getCounts().add(ProfileSupport.count("rows", scope.estimate() ? scope.total() : rows, table, drillFilter, null));
result.getCounts().add(ProfileSupport.count("nonNull", counts.nonNull(), table, drillFilter, DrillFilters.nonNull(name, field.getType().isStringLike())));
result.getCounts().add(ProfileSupport.count("nulls", counts.nulls(), table, drillFilter, DrillFilters.nulls(name, field.getType().isStringLike())));
if(field.getType().isStringLike())
{
result.getCounts().add(ProfileSupport.count("blanks", counts.blanks(), table, drillFilter, counts.blankFilter()));
}
result.getCounts().add(ProfileSupport.count("distinct", counts.distinct(), table, drillFilter, counts.distinctFilter()));
result.getCounts().add(ProfileSupport.metric("uniqueness", counts.uniqueness(), ValueType.PERCENT));
result.withTopValues(ProfileSupport.values(table, drillFilter, field, backend.topValues(table, filter, name, 20, 0, null), rows, fieldAccess));
QFieldType type = field.getType();
if(type == QFieldType.INTEGER || type == QFieldType.LONG || type == QFieldType.DECIMAL)
{
numeric(result, filter, drillFilter, rows, field);
}
else if(type == QFieldType.DATE || type == QFieldType.DATE_TIME)
{
dates(result, filter, drillFilter, rows, type);
}
else if(type.isStringLike())
{
AnalysisBackend.LengthSummary summary = backend.lengthSummary(table, filter, name);
AnalysisBackend.PatternSummary patterns = backend.patterns(table, filter, name, 10_000);
long patternValues = patterns.values();
TextProfile text = new TextProfile().withHistogram(ProfileSupport.values(table, drillFilter, null, summary.histogram(), rows))
.withPatterns(ProfileSupport.values(table, drillFilter, null, patterns.groups(), patternValues))
.withSampleRows(patternValues);
summary.metrics().forEach((key, value) -> text.getMetrics().add(ProfileSupport.metric(key, value, ValueType.DECIMAL)));
result.withText(text);
if(patterns.truncated())
{
result.getWarnings().add(scope.samplePercent() == null ? "Patterns are based on the first " + AnalysisFormatting.number(patternValues, 0) + " values."
: "Patterns are based on " + AnalysisFormatting.number(patternValues, 0) + " sampled values.");
}
}
else if(type == QFieldType.BOOLEAN)
{
result.withBooleans(new java.util.ArrayList<>());
for(boolean value : List.of(true, false))
{
QQueryFilter condition = DrillFilters.value(name, value, false);
result.getBooleans().add(ProfileSupport.count(Boolean.toString(value), backend.count(table, DrillFilters.and(filter, condition)), table, drillFilter, condition));
}
result.getBooleans().add(ProfileSupport.count("nulls", counts.nulls(), table, drillFilter, DrillFilters.nulls(name, false)));
}
new MinGroupSizeGuard(config.getMinGroupSize()).column(result, rows);
if(scope.estimate())
{
ProfileSupport.markEstimates(result.getCounts());
ProfileSupport.markEstimates(result.getBooleans());
if(result.getNumeric() != null)
{
ProfileSupport.markEstimates(result.getNumeric().getMetrics());
}
if(result.getDates() != null)
{
ProfileSupport.markEstimates(result.getDates().getMetrics());
}
if(result.getText() != null)
{
ProfileSupport.markEstimates(result.getText().getMetrics());
}
}
if(field.getPossibleValueSourceName() != null || result.getNumeric() == null && result.getDates() == null)
{
result.withChart(ProfileSupport.chart(ChartKind.BAR, ChartKind.PARETO, name, result.getTopValues()));
}
else if(result.getNumeric() != null)
{
result.withChart(ProfileSupport.chart(ChartKind.HISTOGRAM, ChartKind.BOX, name, result.getNumeric().getHistogram()));
}
else
{
result.withChart(ProfileSupport.chart(ChartKind.TIME_SERIES, ChartKind.BAR, name, result.getDates().getBuckets()));
result.getChart().getX().withLabel(field.getLabel() + (result.getDates().getUnit() == null ? "" : " (" + result.getDates().getUnit().toLowerCase(java.util.Locale.ROOT) + ")"));
}
if(scope.total() == 0)
{
result.getWarnings().add("No readable records match this analysis scope.");
}
else if(counts.nonNull() == 0 && rows > 0 && (!scope.estimate() || scope.rowsScanned() > 0))
{
result.getWarnings().add(scope.estimate() ? "All observed values are null." : "All values in this analysis scope are null.");
}
if(scope.estimate())
{
result.getWarnings().add(scope.warning());
if(scope.rowsScanned() == 0)
{
result.getWarnings().add("No sample rows were observed; run exact for exact numbers.");
}
}
if(scope.truncated())
{
result.getWarnings().add(scope.estimate() ? "Partial result: max_rows reached; counts cover the selected prefix and drills cover the full population."
: "Partial result: max_rows reached; counts and drills cover only the selected prefix.");
}
return (result);
}
/*******************************************************************************
** Numeric moments, nice interior bins, exterior bins and Tukey outliers.
*******************************************************************************/
private void numeric(ColumnProfile result, QQueryFilter filter, QQueryFilter drillFilter, long rows, QFieldMetaData metadata) throws QException
{
String table = result.getTableName();
String field = result.getFieldName();
AnalysisBackend.NumericSummary summary = backend.numericSummary(table, filter, field,
List.of("0.01", "0.05", "0.25", "0.50", "0.75", "0.95", "0.99").stream().map(BigDecimal::new).toList());
NumericProfile numeric = new NumericProfile();
summary.metrics().forEach((key, value) -> numeric.getMetrics().add(AnalysisFormatting.numericMetric(metadata, key, value)));
for(AnalysisBackend.GroupCount count : summary.counts())
{
numeric.getMetrics().add(ProfileSupport.count(count.label(), count.count(), table, drillFilter, count.condition()));
}
numeric.getMetrics().add(ProfileSupport.count("outliers", summary.outlierCount(), table, drillFilter, summary.outlierFilter()));
numeric.withHistogram(ProfileSupport.values(table, drillFilter, null,
backend.numericHistogram(table, filter, field, summary.metrics().get("p1"), summary.metrics().get("p99"), 20), rows));
numeric.withOutliers(ProfileSupport.values(table, drillFilter, null, summary.outliers(), rows));
result.withNumeric(numeric);
}
/*******************************************************************************
** Date extrema, range, calendar volume, gaps and cyclic time patterns.
*******************************************************************************/
private void dates(ColumnProfile result, QQueryFilter filter, QQueryFilter drillFilter, long rows, QFieldType type) throws QException
{
String table = result.getTableName();
String field = result.getFieldName();
ZoneId zone = ValueUtils.getSessionOrInstanceZoneId();
Map<String, Serializable> extrema = backend.extrema(table, filter, field);
DateProfile dates = new DateProfile();
ValueType valueType = type == QFieldType.DATE ? ValueType.DATE : ValueType.DATE_TIME;
extrema.forEach((key, value) -> dates.getMetrics().add(ProfileSupport.metric(key, value, valueType)));
if(extrema.get("min") != null && extrema.get("max") != null)
{
LocalDate first = ProfileSupport.day(extrema.get("min"), zone);
LocalDate last = ProfileSupport.day(extrema.get("max"), zone);
AnalysisBackend.DateUnit unit = autoBucket(first, last);
dates.withUnit(unit.name());
dates.getMetrics().add(ProfileSupport.metric("range", ChronoUnit.DAYS.between(first, last), ValueType.DURATION).withFormatted(AnalysisFormatting.duration(first, last)));
List<ValueCount> buckets = ProfileSupport.values(table, drillFilter, null, backend.dateBuckets(table, filter, field, unit, zone), rows);
dates.withBuckets(buckets).withGaps(buckets.stream().filter(b -> b.getCount() == 0).toList());
if(type == QFieldType.DATE_TIME)
{
dates.withDayOfWeek(ProfileSupport.values(table, drillFilter, null, backend.dateBuckets(table, filter, field, AnalysisBackend.DateUnit.DAY_OF_WEEK, zone), rows))
.withHourOfDay(ProfileSupport.values(table, drillFilter, null, backend.dateBuckets(table, filter, field, AnalysisBackend.DateUnit.HOUR_OF_DAY, zone), rows));
}
}
result.withDates(dates);
}
/*******************************************************************************
** Inclusive calendar boundaries: 92 days, two years and ten years.
*******************************************************************************/
public static AnalysisBackend.DateUnit autoBucket(LocalDate first, LocalDate last)
{
if(ChronoUnit.DAYS.between(first, last) <= 92)
{
return (AnalysisBackend.DateUnit.DAY);
}
if(!last.isAfter(first.plusYears(2)))
{
return (AnalysisBackend.DateUnit.WEEK);
}
return (!last.isAfter(first.plusYears(10)) ? AnalysisBackend.DateUnit.MONTH : AnalysisBackend.DateUnit.YEAR);
}
}
@@ -0,0 +1,254 @@
/*
* 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.engine;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.CompareRequest;
import com.kingsrook.qbits.dataanalysis.result.CompareResult;
import com.kingsrook.qbits.dataanalysis.result.ComparisonSide;
import com.kingsrook.qbits.dataanalysis.result.ComparisonValue;
import com.kingsrook.qbits.dataanalysis.result.FieldComparison;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.Point;
import com.kingsrook.qbits.dataanalysis.result.Series;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qbits.dataanalysis.stats.Statistics;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Two populations share category boundaries and exact producing predicates.
*******************************************************************************/
public class ComparisonExplorer extends RelationshipSupport
{
/*******************************************************************************
** Bind request-local policy without modifying session metadata.
*******************************************************************************/
public ComparisonExplorer(AnalysisSecurity security, EffectiveConfig config, String table)
{
super(security, config, table);
}
/*******************************************************************************
** Resolve every field before issuing source queries.
*******************************************************************************/
public CompareResult compare(CompareRequest request) throws QException
{
if(request.getFields() == null || request.getFields().isEmpty() || request.getFields().size() > 100
|| request.getFields().stream().distinct().count() != request.getFields().size())
{
throw new QBadRequestException("Choose 1 to 100 distinct analysis fields.");
}
for(String field : request.getFields())
{
full(field);
}
CompareResult result = new CompareResult();
for(String field : request.getFields())
{
result.getFields().add(numeric(full(field)) ? numericComparison(request, field) : categoryComparison(request, field));
}
return (result);
}
/*******************************************************************************
** All moments and quartiles are computed together by core, including VAR_SAMP.
*******************************************************************************/
protected Map<String, BigDecimal> moments(String field, QQueryFilter filter) throws QException
{
Map<String, Aggregate> measures = new LinkedHashMap<>();
measures.put("count", measure(field, AggregateOperator.COUNT));
measures.put("mean", measure(field, AggregateOperator.AVG));
measures.put("variance", measure(field, AggregateOperator.VAR_SAMP));
measures.put("min", measure(field, AggregateOperator.MIN));
measures.put("max", measure(field, AggregateOperator.MAX));
for(int p : List.of(25, 50, 75))
{
measures.put("p" + p, measure(field, AggregateOperator.PERCENTILE_CONT).withPercentile(BigDecimal.valueOf(p, 2)));
}
var row = summarize(filter, new ArrayList<>(measures.values()));
Map<String, BigDecimal> result = new LinkedHashMap<>();
measures.forEach((name, aggregate) -> result.put(name, decimal(row.getAggregateValues().get(aggregate))));
result.putIfAbsent("count", BigDecimal.ZERO);
return (result);
}
/*******************************************************************************
** Two BOX series retain all five summary values; mean and median get drills.
*******************************************************************************/
private FieldComparison numericComparison(CompareRequest request, String field) throws QException
{
Map<String, BigDecimal> a = moments(field, request.getFilterA());
Map<String, BigDecimal> b = moments(field, request.getFilterB());
long nA = a.get("count").longValue();
long nB = b.get("count").longValue();
ComparisonSide sideA = numericSide(field, request.getLabelA(), request.getFilterA(), a);
ComparisonSide sideB = numericSide(field, request.getLabelB(), request.getFilterB(), b);
ComparisonValue value = new ComparisonValue().withSideA(sideA).withSideB(sideB).withLabel(field);
boolean hidden = nA < config.getMinGroupSize() || nB < config.getMinGroupSize();
if(!hidden)
{
deltas(value, a.get("mean"), b.get("mean"));
}
Statistics.Result test = hidden ? Statistics.unavailable("Welch's t-test", "privacy guard")
: Statistics.welch(nA, number(a, "mean"), number(a, "variance"), nB, number(b, "mean"), number(b, "variance"));
return (new FieldComparison().withField(field).withValues(List.of(value)).withSignificance(significance(test))
.withChart(chart(ChartKind.BOX, List.of(box(sideA), box(sideB)), "Two box summaries; delta is B minus A.")));
}
/*******************************************************************************
** Null moments remain invalid test inputs rather than becoming zero variance.
*******************************************************************************/
private static double number(Map<String, BigDecimal> values, String key)
{
return (values.get(key) == null ? Double.NaN : values.get(key).doubleValue());
}
/*******************************************************************************
** Count means non-null numeric records; every summary drills to that set.
*******************************************************************************/
protected ComparisonSide numericSide(String field, String label, QQueryFilter filter, Map<String, BigDecimal> values)
{
ComparisonSide result = new ComparisonSide().withLabel(label);
for(String key : List.of("count", "mean", "min", "p25", "p50", "p75", "max"))
{
String name = key.equals("p50") ? "median" : key;
result.getMetrics().add(metric(name, values.get(key), key.equals("count") ? ValueType.COUNT : ValueType.DECIMAL, filter, DrillFilters.nonNull(field, false)));
}
guard(result.getMetrics(), values.get("count").longValue());
return (result);
}
/*******************************************************************************
** Only guarded summary metrics enter charts.
*******************************************************************************/
private Series box(ComparisonSide side)
{
Series series = new Series().withName(side.getLabel());
side.getMetrics().stream().filter(m -> !List.of("count", "mean").contains(m.getKey()) && m.getValue() != null)
.forEach(m -> series.getPoints().add(new Point().withX(m.getKey()).withY(decimal(m.getValue())).withLabel(m.getLabel()).withDrill(m.getDrill())));
return (series);
}
/*******************************************************************************
** Top ten across the union gives both populations the same categories.
** Expected counts below five pool into Other before display and inference.
*******************************************************************************/
private FieldComparison categoryComparison(CompareRequest request, String field) throws QException
{
QQueryFilter union = request.getFilterA() == null || request.getFilterB() == null ? null
: new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(DrillFilters.copy(request.getFilterA())).withSubFilter(DrillFilters.copy(request.getFilterB()));
long nA = size(request.getFilterA());
long nB = size(request.getFilterB());
List<Category> kept = new ArrayList<>();
List<Category> other = new ArrayList<>();
boolean privateOther = false;
for(Category category : categories(field, union, 10))
{
long a = size(DrillFilters.and(request.getFilterA(), category.condition()));
long b = size(DrillFilters.and(request.getFilterB(), category.condition()));
boolean hidden = category.privateGroup() || a < config.getMinGroupSize() || b < config.getMinGroupSize();
double pooled = nA + nB == 0 ? 0 : (double) (a + b) / (nA + nB);
if(hidden || Math.min(nA * pooled, nB * pooled) < 5 || category.label().equals("Other") && category.value() == null)
{
other.add(category);
privateOther |= hidden;
}
else
{
kept.add(category);
}
}
if(!other.isEmpty())
{
kept.add(merge(other, privateOther));
}
double[][] counts = new double[2][kept.size()];
FieldComparison result = new FieldComparison().withField(field);
Series seriesA = new Series().withName(request.getLabelA());
Series seriesB = new Series().withName(request.getLabelB());
for(int i = 0; i < kept.size(); i++)
{
Category category = kept.get(i);
long a = size(DrillFilters.and(request.getFilterA(), category.condition()));
long b = size(DrillFilters.and(request.getFilterB(), category.condition()));
counts[0][i] = a;
counts[1][i] = b;
ComparisonSide sideA = categorySide(request.getLabelA(), request.getFilterA(), category, a, nA);
ComparisonSide sideB = categorySide(request.getLabelB(), request.getFilterB(), category, b, nB);
ComparisonValue value = new ComparisonValue().withValue(category.value()).withLabel(category.label()).withSideA(sideA).withSideB(sideB);
boolean hidden = a < config.getMinGroupSize() || b < config.getMinGroupSize();
if(!hidden)
{
deltas(value, ProfileSupport.share(a, nA), ProfileSupport.share(b, nB));
}
value.withSignificance(significance(hidden || category.privateGroup() ? Statistics.unavailable("Two-proportion z-test", "privacy guard") : Statistics.proportions(a, nA, b, nB)));
result.getValues().add(value);
categoryPoint(seriesA, category, sideA);
categoryPoint(seriesB, category, sideB);
}
result.withSignificance(significance(privateOther ? Statistics.unavailable("Chi-square homogeneity", "privacy guard") : Statistics.chiSquare(counts, true)));
return (result.withChart(chart(ChartKind.BAR, List.of(seriesA, seriesB), "Grouped bars; delta is B minus A. Low expected counts are merged into Other.")));
}
/*******************************************************************************
** Sensitive merged categories reveal no predicate or original value.
*******************************************************************************/
private ComparisonSide categorySide(String label, QQueryFilter filter, Category category, long n, long total)
{
ComparisonSide side = new ComparisonSide().withLabel(label).withMetrics(List.of(metric("count", n, ValueType.COUNT, filter, category.condition()),
metric("share", ProfileSupport.share(n, total), ValueType.PERCENT, filter, category.condition())));
if(category.privateGroup())
{
side.getMetrics().forEach(m -> m.withDrill(null));
}
guard(side.getMetrics(), n);
return (side);
}
/*******************************************************************************
** Charts reflect only released count metrics.
*******************************************************************************/
private void categoryPoint(Series series, Category category, ComparisonSide side)
{
Metric count = side.getMetrics().getFirst();
series.getPoints().add(new Point().withX(category.label()).withLabel(category.label()).withY(decimal(count.getValue())).withDrill(count.getDrill()));
}
/*******************************************************************************
** Percent delta is a fraction relative to |A|; zero baseline is undefined.
*******************************************************************************/
private void deltas(ComparisonValue value, BigDecimal a, BigDecimal b)
{
BigDecimal delta = a == null || b == null ? null : b.subtract(a);
value.withDelta(ProfileSupport.metric("delta", delta, ValueType.DECIMAL));
value.withPercentDelta(ProfileSupport.metric("percentDelta", delta == null || a.signum() == 0 ? null : delta.divide(a.abs(), MathContext.DECIMAL128), ValueType.PERCENT));
}
}
@@ -0,0 +1,483 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateCapability;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucket;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucketUnit;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.NumericBucket;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Capability-directed aggregates; streamed primitives retain portable drills.
*******************************************************************************/
public class CoreAnalysisBackend extends StandInAnalysisBackend
{
private BigDecimal samplePercent;
private QQueryFilter populationFilter;
private long populationRows;
private long aggregateRows;
private BigDecimal lastCountShare;
/*******************************************************************************
** Bind the same bounded configuration as the streamed implementation.
*******************************************************************************/
public CoreAnalysisBackend(EffectiveConfig config)
{
super(config);
}
/*******************************************************************************
** Ask the actual backend implementation, including dialect-specific support.
*******************************************************************************/
public static Set<AggregateCapability> capabilities(String table) throws QException
{
AggregateInput input = new AggregateInput(table);
return (new QBackendModuleDispatcher().getQBackendModule(input.getBackend()).getAggregateInterface().getSupportedCapabilities(input));
}
/*******************************************************************************
** Sampling belongs to each aggregate, never to a materialized key filter.
*******************************************************************************/
@Override
public Scope scope(String table, QQueryFilter filter, boolean exact) throws QException
{
samplePercent = null;
long total = super.count(table, filter);
if(exact || total <= config.getSampleThresholdRows() || total <= config.getSampleRows() || !capabilities(table).contains(AggregateCapability.SAMPLING))
{
return (super.scope(table, filter, exact));
}
estimating = true;
populationRows = total;
populationFilter = DrillFilters.copy(filter);
samplePercent = BigDecimal.valueOf(config.getSampleRows()).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total), MathContext.DECIMAL128).min(BigDecimal.valueOf(100));
long seen = sampledCount(table, populationFilter);
return (new Scope(DrillFilters.copy(filter), total, seen, false, true, samplePercent));
}
/*******************************************************************************
** Count the sampled call's actual rows rather than assuming the requested size.
*******************************************************************************/
private long sampledCount(String table, QQueryFilter filter) throws QException
{
if(samplePercent.signum() == 0)
{
return (0);
}
List<AggregateResult> result = super.aggregate(new AggregateInput(table).withFilter(DrillFilters.copy(filter))
.withAggregate(countOf(primaryKey(table), AggregateOperator.COUNT)).withSamplePercent(samplePercent));
return (result.isEmpty() ? 0 : number(result.getFirst(), primaryKey(table), AggregateOperator.COUNT).longValue());
}
/*******************************************************************************
** Unpaginated groups supply their own denominator, including null groups.
** Limited groups use a separate sampled population count for this call.
*******************************************************************************/
@Override
protected List<AggregateResult> aggregate(AggregateInput input) throws QException
{
if(samplePercent == null)
{
return (super.aggregate(input));
}
if(samplePercent.signum() == 0)
{
aggregateRows = 0;
return (List.of());
}
Aggregate count = countOf(primaryKey(input.getTableName()), AggregateOperator.COUNT);
if(input.getAggregates() == null || !input.getAggregates().contains(count))
{
input.withAggregate(count);
}
input.withSamplePercent(samplePercent);
boolean fullPopulation = JsonUtils.toJson(DrillFilters.copy(input.getFilter())).equals(JsonUtils.toJson(populationFilter));
List<AggregateResult> result = super.aggregate(input);
aggregateRows = fullPopulation && input.getLimit() == null
? result.stream().mapToLong(r -> number(r, primaryKey(input.getTableName()), AggregateOperator.COUNT).longValue()).sum()
: sampledCount(input.getTableName(), populationFilter);
return (result);
}
/*******************************************************************************
** Population rows remain exact; conditional counters estimate their membership.
*******************************************************************************/
@Override
public long count(String table, QQueryFilter filter) throws QException
{
if(samplePercent == null)
{
return (super.count(table, filter));
}
if(JsonUtils.toJson(DrillFilters.copy(filter)).equals(JsonUtils.toJson(populationFilter)))
{
lastCountShare = ProfileSupport.share(populationRows, populationRows);
return (populationRows);
}
long value = sampledCount(table, filter);
aggregateRows = sampledCount(table, populationFilter);
lastCountShare = ProfileSupport.share(value, aggregateRows);
return (aggregateCount(value));
}
/*******************************************************************************
** Round count estimates only after normalizing by observed sample rows.
*******************************************************************************/
@Override
protected long aggregateCount(long value)
{
return (samplePercent == null ? value : aggregateRows == 0 ? 0 : BigDecimal.valueOf(value).multiply(BigDecimal.valueOf(populationRows))
.divide(BigDecimal.valueOf(aggregateRows), 0, RoundingMode.HALF_UP).longValueExact());
}
/*******************************************************************************
** Keep ratios in sample units, independent of count rounding.
*******************************************************************************/
@Override
protected BigDecimal aggregateShare(long value)
{
return (samplePercent == null ? null : ProfileSupport.share(value, aggregateRows));
}
/*******************************************************************************
** Conditional groups retain the ratio from their sampled count call.
*******************************************************************************/
@Override
protected BigDecimal countShare()
{
return (samplePercent == null ? null : lastCountShare);
}
/*******************************************************************************
** Streamed fallbacks also observe native sampling, without binding sampled ids.
*******************************************************************************/
@Override
protected List<QRecord> rows(String table, QQueryFilter filter, Set<String> fields, int limit) throws QException
{
if(samplePercent == null)
{
return (super.rows(table, filter, fields, limit));
}
if(limit == 0 || config.getMaxRows() == 0)
{
return (List.of());
}
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter)).withGroupBy(new GroupBy(field(table, primaryKey(table))))
.withLimit(Math.min(limit, config.getMaxRows()));
fields.stream().filter(f -> !f.equals(primaryKey(table))).forEach(f -> input.withGroupBy(new GroupBy(field(table, f))));
List<QRecord> result = new ArrayList<>();
for(AggregateResult row : aggregate(input))
{
QRecord record = new QRecord();
row.getGroupByValues().forEach((key, value) -> record.setValue(key.getFieldName(), value));
result.add(record);
}
return (result);
}
/*******************************************************************************
** Every field batch shares a COUNT denominator from the same sampled call.
*******************************************************************************/
@Override
public Map<String, FieldCounts> fieldCounts(String table, QQueryFilter filter, List<String> fields) throws QException
{
if(samplePercent == null)
{
return (super.fieldCounts(table, filter, fields));
}
Map<String, FieldCounts> result = new LinkedHashMap<>();
for(int start = 0; start < fields.size(); start += 20)
{
List<String> batch = fields.subList(start, Math.min(start + 20, fields.size()));
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter));
for(String field : batch)
{
input.withAggregate(countOf(field, AggregateOperator.COUNT)).withAggregate(countOf(field, AggregateOperator.COUNT_DISTINCT));
}
fieldCountAggregates(input, batch);
List<String> stringFields = batch.stream().filter(f -> field(table, f).getType().isStringLike()).toList();
List<QRecord> records = stringFields.isEmpty() ? List.of() : rows(table, filter, Set.copyOf(stringFields), config.getMaxRows());
List<AggregateResult> results = aggregate(input);
long seen = aggregateRows;
for(String field : batch)
{
boolean string = field(table, field).getType().isStringLike();
long nonNull = results.isEmpty() ? 0 : number(results.getFirst(), field, AggregateOperator.COUNT).longValue();
long distinct = results.isEmpty() ? 0 : number(results.getFirst(), field, AggregateOperator.COUNT_DISTINCT).longValue();
long blanks = string && capabilities(table).contains(AggregateCapability.NULL_BLANK_COUNTS) && !results.isEmpty()
? number(results.getFirst(), field, AggregateOperator.COUNT_BLANK).longValue() : seen - nonNull;
QQueryFilter blankFilter = DrillFilters.nulls(field, string);
if(string)
{
blankFilter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(blankFilter);
for(String blank : records.stream().map(r -> r.getValueString(field)).filter(Objects::nonNull).filter(String::isBlank).distinct().toList())
{
blankFilter.withSubFilter(DrillFilters.condition(field, QCriteriaOperator.EQUALS, blank));
}
}
long blankCount = aggregateCount(blanks);
BigDecimal completeness = ProfileSupport.share(seen - blanks, seen);
if(string && !capabilities(table).contains(AggregateCapability.NULL_BLANK_COUNTS))
{
blankCount = count(table, DrillFilters.and(filter, blankFilter));
completeness = BigDecimal.ONE.subtract(countShare());
aggregateRows = seen;
}
result.put(field, new FieldCounts(aggregateCount(nonNull), aggregateCount(seen - nonNull), blankCount, aggregateCount(distinct),
blankFilter, DrillFilters.nonNull(field, string), ProfileSupport.share(distinct, nonNull), completeness, distinct == 1));
}
}
return (result);
}
/*******************************************************************************
** Add supported counters to the existing twenty-field aggregate batch.
*******************************************************************************/
@Override
protected void fieldCountAggregates(AggregateInput input, List<String> fields) throws QException
{
if(capabilities(input.getTableName()).contains(AggregateCapability.NULL_BLANK_COUNTS))
{
for(String name : fields)
{
input.withAggregate(new Aggregate(name, AggregateOperator.COUNT_NULL));
if(field(input.getTableName(), name).getType().isStringLike())
{
input.withAggregate(new Aggregate(name, AggregateOperator.COUNT_BLANK));
}
}
}
}
/*******************************************************************************
** Read native counters, falling back to the portable predicate counter.
*******************************************************************************/
@Override
protected long fieldCount(String table, QQueryFilter filter, String field, AggregateOperator operator,
List<AggregateResult> results, QQueryFilter condition) throws QException
{
if(capabilities(table).contains(AggregateCapability.NULL_BLANK_COUNTS))
{
return (results.isEmpty() ? 0 : number(results.getFirst(), field, operator).longValue());
}
return (super.fieldCount(table, filter, field, operator, results, condition));
}
/*******************************************************************************
** Push each supported statistic, streaming only unavailable operators.
*******************************************************************************/
@Override
protected Map<String, BigDecimal> numericMetrics(String table, QQueryFilter filter, String field, List<BigDecimal> percentiles, List<BigDecimal> values) throws QException
{
Set<AggregateCapability> supported = capabilities(table);
if(!supported.containsAll(Set.of(AggregateCapability.STDDEV, AggregateCapability.PERCENTILE)))
{
return (partialNumericMetrics(table, filter, field, percentiles, values, supported));
}
return (nativeNumericMetrics(table, filter, field, percentiles, supported));
}
/*******************************************************************************
** Existing streamed arithmetic supplies only unavailable metrics.
*******************************************************************************/
private Map<String, BigDecimal> partialNumericMetrics(String table, QQueryFilter filter, String field, List<BigDecimal> percentiles,
List<BigDecimal> values, Set<AggregateCapability> supported) throws QException
{
Map<String, BigDecimal> metrics = super.numericMetrics(table, filter, field, percentiles, values);
metrics.putAll(nativeNumericMetrics(table, filter, field, percentiles, supported));
return (metrics);
}
/*******************************************************************************
** One aggregate call computes basic moments and all supported extensions.
*******************************************************************************/
private Map<String, BigDecimal> nativeNumericMetrics(String table, QQueryFilter filter, String field, List<BigDecimal> percentiles,
Set<AggregateCapability> supported) throws QException
{
Map<String, BigDecimal> metrics = new LinkedHashMap<>();
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter));
Map<AggregateOperator, String> names = Map.of(AggregateOperator.MIN, "min", AggregateOperator.MAX, "max", AggregateOperator.AVG, "mean", AggregateOperator.SUM, "sum", AggregateOperator.STDDEV_SAMP, "stddev");
for(AggregateOperator op : names.keySet())
{
if(op != AggregateOperator.STDDEV_SAMP || supported.contains(AggregateCapability.STDDEV))
{
input.withAggregate(new Aggregate(field, op));
}
}
if(supported.contains(AggregateCapability.PERCENTILE))
{
percentiles.forEach(p -> input.withAggregate(new Aggregate(field, AggregateOperator.PERCENTILE_CONT).withPercentile(p)));
}
for(AggregateResult row : aggregate(input))
{
row.getAggregateValues().entrySet().stream().filter(e -> e.getKey().getOperator() != AggregateOperator.COUNT).forEach(entry ->
{
Aggregate key = entry.getKey();
Serializable value = entry.getValue();
metrics.put(key.getOperator() == AggregateOperator.PERCENTILE_CONT
? "p" + key.getPercentile().multiply(BigDecimal.valueOf(100)).stripTrailingZeros().toPlainString() : names.get(key.getOperator()),
value == null ? null : new BigDecimal(value.toString()));
});
}
return (metrics);
}
/*******************************************************************************
** Convert native bin numbers into identical half-open drill predicates.
*******************************************************************************/
@Override
public List<GroupCount> numericHistogram(String table, QQueryFilter filter, String field, BigDecimal low, BigDecimal high, int bins) throws QException
{
if(low == null || high == null || low.compareTo(high) >= 0 || !capabilities(table).contains(AggregateCapability.NUMERIC_BUCKET))
{
return (super.numericHistogram(table, filter, field, low, high, bins));
}
NiceBins nice = NiceBins.covering(low, high);
low = nice.low();
high = nice.high();
bins = nice.count();
GroupBy group = new GroupBy(field(table, field)).withBucket(new NumericBucket(low, high, bins));
Map<Serializable, Long> counts = bucketCounts(table, filter, group);
List<BigDecimal> values = !estimating && isMemory(table) ? rows(table, filter, Set.of(field), config.getMaxRows()).stream().map(r -> r.getValue(field))
.filter(Objects::nonNull).map(v -> new BigDecimal(v.toString())).toList() : List.of();
List<GroupCount> result = new ArrayList<>();
result.add(new GroupCount(AnalysisFormatting.interval(field(table, field), null, low), AnalysisFormatting.interval(field(table, field), null, low), aggregateCount(counts.getOrDefault(0, 0L)), numericRange(table, field, values, null, low, false), aggregateShare(counts.getOrDefault(0, 0L))));
BigDecimal width = high.subtract(low).divide(BigDecimal.valueOf(bins), MathContext.DECIMAL128);
for(int i = 0; i < bins; i++)
{
BigDecimal start = low.add(width.multiply(BigDecimal.valueOf(i)));
BigDecimal end = i == bins - 1 ? high : low.add(width.multiply(BigDecimal.valueOf(i + 1)));
String label = AnalysisFormatting.interval(field(table, field), start, end);
result.add(new GroupCount(label, label, aggregateCount(counts.getOrDefault(i + 1, 0L)), numericRange(table, field, values, start, end, i == bins - 1), aggregateShare(counts.getOrDefault(i + 1, 0L))));
}
result.add(new GroupCount(AnalysisFormatting.interval(field(table, field), high, null), AnalysisFormatting.interval(field(table, field), high, null), aggregateCount(counts.getOrDefault(bins + 1, 0L)),
DrillFilters.and(DrillFilters.condition(field, QCriteriaOperator.IS_NOT_BLANK), DrillFilters.condition(field, QCriteriaOperator.GREATER_THAN, high)), aggregateShare(counts.getOrDefault(bins + 1, 0L))));
return (result);
}
/*******************************************************************************
** Native dates use the session zone; fill calendar gaps with zero counts.
*******************************************************************************/
@Override
public List<GroupCount> dateBuckets(String table, QQueryFilter filter, String field, DateUnit unit, ZoneId zone) throws QException
{
if(unit == DateUnit.HOUR || unit == DateUnit.QUARTER)
{
return (super.dateBuckets(table, filter, field, unit, zone));
}
GroupBy group = new GroupBy(field(table, field)).withBucket(new DateBucket(DateBucketUnit.valueOf(unit.name())));
AggregateInput probe = new AggregateInput(table).withGroupBy(group);
if(!new QBackendModuleDispatcher().getQBackendModule(probe.getBackend()).getAggregateInterface().getSupportedCapabilities(probe).contains(AggregateCapability.DATE_BUCKET))
{
return (super.dateBuckets(table, filter, field, unit, zone));
}
Map<Serializable, Long> counts = bucketCounts(table, filter, group);
if(unit == DateUnit.DAY_OF_WEEK || unit == DateUnit.HOUR_OF_DAY)
{
long seen = aggregateRows;
List<GroupCount> conditions = super.dateBuckets(table, filter, field, unit, zone);
aggregateRows = seen;
return (conditions.stream()
.map(g -> new GroupCount(g.value(), g.label(), aggregateCount(counts.getOrDefault(g.value(), 0L)), g.condition(), aggregateShare(counts.getOrDefault(g.value(), 0L)))).toList());
}
List<LocalDate> dates = counts.keySet().stream().filter(Objects::nonNull).map(v -> (LocalDate) v).sorted().toList();
List<GroupCount> result = new ArrayList<>();
if(!dates.isEmpty())
{
LocalDate start = dates.getFirst();
while(!start.isAfter(dates.getLast()))
{
LocalDate end = next(start, unit);
String label = AnalysisFormatting.dateBucket(start, unit, zone);
result.add(new GroupCount(start, label, aggregateCount(counts.getOrDefault(start, 0L)),
DrillFilters.dateRange(field, start, end, field(table, field).getType() == QFieldType.DATE_TIME, zone), aggregateShare(counts.getOrDefault(start, 0L))));
start = end;
}
}
return (result);
}
/*******************************************************************************
** Length moments are native; the derived length histogram remains streamed.
*******************************************************************************/
@Override
public LengthSummary lengthSummary(String table, QQueryFilter filter, String field) throws QException
{
LengthSummary streamed = super.lengthSummary(table, filter, field);
if(capabilities(table).contains(AggregateCapability.STRING_LENGTH))
{
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter));
Map<AggregateOperator, String> names = Map.of(AggregateOperator.MIN_LENGTH, "minLength", AggregateOperator.MAX_LENGTH, "maxLength", AggregateOperator.AVG_LENGTH, "meanLength");
names.keySet().forEach(op -> input.withAggregate(new Aggregate(field, op)));
for(AggregateResult row : aggregate(input))
{
row.getAggregateValues().forEach((key, value) ->
{
if(value != null && names.containsKey(key.getOperator()))
{
streamed.metrics().put(names.get(key.getOperator()), new BigDecimal(value.toString()));
}
});
}
}
return (streamed);
}
/*******************************************************************************
** Bucket result keys retain core's typed date or integer values.
*******************************************************************************/
private Map<Serializable, Long> bucketCounts(String table, QQueryFilter filter, GroupBy group) throws QException
{
Map<Serializable, Long> result = new LinkedHashMap<>();
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter)).withGroupBy(group)
.withAggregate(countOf(primaryKey(table), AggregateOperator.COUNT));
for(AggregateResult row : aggregate(input))
{
result.put(row.getGroupByValues().get(group), number(row, primaryKey(table), AggregateOperator.COUNT).longValue());
}
return (result);
}
}
@@ -0,0 +1,118 @@
/*
* 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.engine;
import java.lang.reflect.Method;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Optional core bridge: replace with direct calls once QQQ ships the fieldaccess package.
** Class and method discovery happen once; invocation failures fail closed.
*******************************************************************************/
public class CoreTierFieldAccessResolver implements FieldAnalysisAccessResolver
{
private static final CoreTierFieldAccessResolver DEFAULT = discover();
private final Method tier;
private final Method fingerprint;
/*******************************************************************************
** Bind discovered static methods once.
*******************************************************************************/
public CoreTierFieldAccessResolver(Class<?> helper) throws ReflectiveOperationException
{
tier = helper.getMethod("getTier", QTableMetaData.class, String.class);
Method optional;
try
{
optional = helper.getMethod("getAccessFingerprint");
}
catch(NoSuchMethodException e)
{
optional = null;
}
fingerprint = optional;
}
/*******************************************************************************
** Resolve the optional class once per class loader.
*******************************************************************************/
private static CoreTierFieldAccessResolver discover()
{
try
{
return (new CoreTierFieldAccessResolver(Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper")));
}
catch(ClassNotFoundException e)
{
return (null);
}
catch(ReflectiveOperationException e)
{
throw new ExceptionInInitializerError(e);
}
}
/*******************************************************************************
** Use legacy locks only when the core helper class is absent.
*******************************************************************************/
public static FieldAnalysisAccessResolver defaultResolver()
{
return (DEFAULT == null ? new FieldSecurityLockFieldAccessResolver() : DEFAULT);
}
/*******************************************************************************
** Unknown tiers and invocation failures never grant analysis rights.
*******************************************************************************/
@Override
public FieldAnalysisAccess resolve(QTableMetaData table, QFieldMetaData field)
{
try
{
return (switch(String.valueOf(tier.invoke(null, table, field.getName())))
{
case "FULL" -> FieldAnalysisAccess.FULL;
case "REVEALABLE" -> FieldAnalysisAccess.EXACT_MATCH_ONLY;
default -> FieldAnalysisAccess.NONE;
});
}
catch(ReflectiveOperationException e)
{
return (FieldAnalysisAccess.NONE);
}
}
/*******************************************************************************
** A broken fingerprint prevents cache reuse rather than returning stale data.
*******************************************************************************/
@Override
public String getAccessFingerprint()
{
try
{
return (fingerprint == null ? "" : (String) fingerprint.invoke(null));
}
catch(ReflectiveOperationException e)
{
throw new IllegalStateException("Could not resolve field access fingerprint.", e);
}
}
}
@@ -0,0 +1,84 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.engine;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesPage;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesRequest;
import com.kingsrook.qbits.dataanalysis.result.ValueCount;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
/*******************************************************************************
** Distinct-value pages apply privacy before pagination, never page rare groups.
*******************************************************************************/
public class DistinctValues
{
private final AnalysisBackend backend;
private final FieldAnalysisAccessResolver fieldAccess;
/*******************************************************************************
** Bind primitive operations.
*******************************************************************************/
public DistinctValues(AnalysisBackend backend)
{
this(backend, CoreTierFieldAccessResolver.defaultResolver());
}
/*******************************************************************************
** Bind the host field policy for possible-value labels.
*******************************************************************************/
public DistinctValues(AnalysisBackend backend, FieldAnalysisAccessResolver fieldAccess)
{
this.backend = backend;
this.fieldAccess = fieldAccess;
}
/*******************************************************************************
** Request validation caps the page size at 500 and rejects overflow.
*******************************************************************************/
public DistinctValuesPage page(DistinctValuesRequest request, QFieldMetaData field, EffectiveConfig config) throws QException
{
if(request.getPage() == null || request.getPage() < 0 || request.getPageSize() == null || request.getPageSize() < 1 || request.getPageSize() > 500
|| (long) request.getPage() * request.getPageSize() > Integer.MAX_VALUE - 501)
{
throw new QException("Invalid distinct-values page; pageSize must be between 1 and 500.");
}
AnalysisBackend.Scope scope = backend.scope(request.getTableName(), request.getFilter(), true);
int offset = request.getPage() * request.getPageSize();
boolean wholeListing = config.getMinGroupSize() > 0 || request.getSearch() != null && !request.getSearch().isBlank();
int fetch = wholeListing ? Math.max(1, config.getMaxRows()) : request.getPageSize() + 1;
List<ValueCount> values = ProfileSupport.values(request.getTableName(), scope.filter(), field,
backend.topValues(request.getTableName(), scope.filter(), field.getName(), fetch, wholeListing ? 0 : offset, request.getSearch()), scope.rowsScanned(), fieldAccess);
long totalDistinct = wholeListing ? values.stream().filter(v -> v.getValue() != null).count()
: backend.fieldCounts(request.getTableName(), scope.filter(), List.of(field.getName())).get(field.getName()).distinct();
values = new MinGroupSizeGuard(config.getMinGroupSize()).groups(values);
int start = wholeListing ? Math.min(offset, values.size()) : 0;
boolean more = values.size() > start + request.getPageSize();
return (new DistinctValuesPage().withTableName(request.getTableName()).withFieldName(field.getName()).withFilter(DrillFilters.copy(request.getFilter()))
.withPage(request.getPage()).withPageSize(request.getPageSize()).withHasMore(more).withValues(values.subList(start, Math.min(values.size(), start + request.getPageSize())))
.withTotalDistinct(totalDistinct)
.withRowsScanned(scope.rowsScanned()).withTruncated(scope.truncated()).withAsOf(Instant.now()));
}
}
@@ -0,0 +1,314 @@
/*
* 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.engine;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.result.Drill;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Immutable composition of the request filter and the producing condition.
*******************************************************************************/
public final class DrillFilters
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DrillFilters()
{
}
/*******************************************************************************
** Copy criteria only; pagination and ordering are not analysis predicates.
*******************************************************************************/
public static QQueryFilter copy(QQueryFilter filter)
{
QQueryFilter result = filter == null ? new QQueryFilter() : filter.clone();
result.setLimit(null);
result.setSkip(null);
result.setOrderBys(null);
if(result.getSubFilters() != null)
{
result.setSubFilters(result.getSubFilters().stream().map(DrillFilters::copy).toList());
}
return (result);
}
/*******************************************************************************
** Preserve OR semantics of both operands by nesting them under AND.
*******************************************************************************/
public static QQueryFilter and(QQueryFilter original, QQueryFilter condition)
{
return (new QQueryFilter().withSubFilter(copy(original)).withSubFilter(copy(condition)));
}
/*******************************************************************************
** A drill always owns its filters.
*******************************************************************************/
public static Drill drill(String table, QQueryFilter original, QQueryFilter condition)
{
QQueryFilter filter = and(original, condition);
var instance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(instance != null && "memory".equals(instance.getBackend(instance.getTable(table).getBackendName()).getBackendType()))
{
filter = memoryTemporalBounds(restoreTypes(table, filter));
}
return (new Drill().withTableName(table).withFilter(filter));
}
/*******************************************************************************
** Memory's inclusive lower bound uses Java equality, which differs after
** HTTP turns dates into strings. Strict lower bounds avoid that equality.
** Only memory uses this adjustment: SQL timestamp precision may be coarser.
*******************************************************************************/
private static QQueryFilter memoryTemporalBounds(QQueryFilter filter)
{
filter.setSubFilters(new java.util.ArrayList<>(filter.getSubFilters()));
for(var criterion : new java.util.ArrayList<>(filter.getCriteria()))
{
if(criterion.getValues() != null && !criterion.getValues().isEmpty()
&& criterion.getValues().stream().allMatch(v -> v instanceof java.time.Instant || v instanceof LocalDate)
&& (criterion.getOperator() == QCriteriaOperator.EQUALS || criterion.getOperator() == QCriteriaOperator.IN))
{
QQueryFilter members = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
for(Serializable value : criterion.getValues())
{
members.withSubFilter(range(criterion.getFieldName(), value, value, true));
}
filter.getCriteria().remove(criterion);
filter.withSubFilter(members);
continue;
}
if(criterion.getValues() != null && criterion.getValues().size() == 1)
{
Serializable value = criterion.getValues().getFirst();
if(value instanceof java.time.Instant || value instanceof LocalDate)
{
Serializable previous = value instanceof java.time.Instant instant ? instant.minusNanos(1) : ((LocalDate) value).minusDays(1);
if(criterion.getOperator() == QCriteriaOperator.GREATER_THAN_OR_EQUALS)
{
criterion.setOperator(QCriteriaOperator.GREATER_THAN);
criterion.setValues(List.of(previous));
}
else if(criterion.getOperator() == QCriteriaOperator.LESS_THAN)
{
criterion.setOperator(QCriteriaOperator.LESS_THAN_OR_EQUALS);
criterion.setValues(List.of(previous));
}
}
}
}
filter.getSubFilters().forEach(DrillFilters::memoryTemporalBounds);
return (filter);
}
/*******************************************************************************
** One criterion, including predicates without operands.
*******************************************************************************/
public static QQueryFilter condition(String field, QCriteriaOperator operator, Serializable... values)
{
return (new QQueryFilter().withCriteria(field, operator, values));
}
/*******************************************************************************
** RC.5 IS_BLANK includes empty strings. Applying it to StringLength
** distinguishes null from length zero on both SQL and memory backends.
*******************************************************************************/
public static QQueryFilter nulls(String field, boolean string)
{
QQueryFilter result = condition(field, QCriteriaOperator.IS_BLANK);
if(string)
{
result.withCriteria(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria(field, QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, 0)
.withFieldFunction(new com.kingsrook.qqq.backend.core.model.metadata.fields.functions.FieldFunction().withFieldName(field)
.withFunctionTypeIdentifier(com.kingsrook.qqq.backend.core.model.metadata.fields.functions.implementations.StringLengthFunction.IDENTIFIER)));
}
return (result);
}
/*******************************************************************************
** Non-null includes an empty string, unlike core IS_NOT_BLANK.
*******************************************************************************/
public static QQueryFilter nonNull(String field, boolean string)
{
QQueryFilter result = condition(field, QCriteriaOperator.IS_NOT_BLANK);
if(string)
{
result.withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(emptyString(field));
}
return (result);
}
/*******************************************************************************
** A group equality uses a null-only filter for a null group.
*******************************************************************************/
public static QQueryFilter value(String field, Serializable value, boolean string)
{
return (value == null ? nulls(field, string) : "".equals(value) ? emptyString(field)
: value instanceof java.time.Instant || value instanceof LocalDate ? range(field, value, value, true) : condition(field, QCriteriaOperator.EQUALS, value));
}
/*******************************************************************************
** Half-open bounds, except that the final interior histogram bin is closed.
** Explicit non-null avoids RC.5 memory treating null as less than a value.
*******************************************************************************/
public static QQueryFilter range(String field, Serializable low, Serializable high, boolean closed)
{
QQueryFilter result = condition(field, QCriteriaOperator.IS_NOT_BLANK);
if(low != null)
{
result.withCriteria(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, low);
}
if(high != null)
{
result.withCriteria(field, closed ? QCriteriaOperator.LESS_THAN_OR_EQUALS : QCriteriaOperator.LESS_THAN, high);
}
return (result);
}
/*******************************************************************************
** Local midnight boundaries preserve DST and the user's time zone.
*******************************************************************************/
public static QQueryFilter dateRange(String field, LocalDate low, LocalDate high, boolean dateTime, ZoneId zone)
{
return (range(field, dateTime ? low.atStartOfDay(zone).toInstant() : low,
dateTime ? high.atStartOfDay(zone).toInstant() : high, false));
}
/*******************************************************************************
** Strict Tukey fences, excluding nulls on every backend.
*******************************************************************************/
public static QQueryFilter outliers(String field, Serializable low, Serializable high)
{
QQueryFilter outside = condition(field, QCriteriaOperator.LESS_THAN, low)
.withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria(field, QCriteriaOperator.GREATER_THAN, high);
return (and(condition(field, QCriteriaOperator.IS_NOT_BLANK), outside));
}
/*******************************************************************************
** Exact membership for streamed shapes, lengths and duplicate tuples.
*******************************************************************************/
public static QQueryFilter in(String field, List<? extends Serializable> values)
{
if(values.stream().anyMatch(v -> v instanceof java.time.Instant || v instanceof LocalDate))
{
QQueryFilter result = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
values.forEach(value -> result.withSubFilter(range(field, value, value, true)));
return (result);
}
if(values.contains(""))
{
List<? extends Serializable> nonEmpty = values.stream().filter(v -> !"".equals(v)).toList();
return (new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(emptyString(field)).withSubFilter(in(field, nonEmpty)));
}
return (values.isEmpty() ? condition(field, QCriteriaOperator.FALSE) : new QQueryFilter().withCriteria(field, QCriteriaOperator.IN, values));
}
/*******************************************************************************
** Core HTTP interpretation treats empty string operands as missing inputs.
** A zero-length predicate expresses the same set without an empty operand.
*******************************************************************************/
private static QQueryFilter emptyString(String field)
{
return (new QQueryFilter().withCriteria(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria(field, QCriteriaOperator.EQUALS, 0)
.withFieldFunction(new com.kingsrook.qqq.backend.core.model.metadata.fields.functions.FieldFunction().withFieldName(field)
.withFunctionTypeIdentifier(com.kingsrook.qqq.backend.core.model.metadata.fields.functions.implementations.StringLengthFunction.IDENTIFIER))));
}
/*******************************************************************************
** JSON has no temporal scalar types. Restore them from current metadata before
** a cached drill is handed back to core, without mutating the stored filter.
*******************************************************************************/
public static QQueryFilter restoreTypes(String table, QQueryFilter filter)
{
QQueryFilter result = copy(filter);
var instance = com.kingsrook.qqq.backend.core.context.QContext.getQInstance();
if(instance == null || instance.getTable(table) == null)
{
return (result);
}
if(result.getCriteria() != null)
{
for(var criterion : result.getCriteria())
{
var field = instance.getTable(table).getFields().get(criterion.getFieldName());
if(field != null && criterion.getValues() != null)
{
var type = field.getType();
if(type == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.DATE
|| type == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.DATE_TIME
|| type == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.DECIMAL
|| criterion.getOperator() == QCriteriaOperator.EQUALS || criterion.getOperator() == QCriteriaOperator.IN)
{
criterion.setValues(criterion.getValues().stream().map(v -> restoreValue(type, v)).toList());
}
}
}
}
if(result.getSubFilters() != null)
{
result.setSubFilters(result.getSubFilters().stream().map(f -> restoreTypes(table, f)).toList());
}
return (result);
}
/*******************************************************************************
** String decimal operands preserve exact scale and precision across JSON round trips.
*******************************************************************************/
private static Serializable restoreValue(com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType type, Serializable value)
{
if(type == com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.DECIMAL)
{
return (value == null ? null : value.toString());
}
return (com.kingsrook.qqq.backend.core.utils.ValueUtils.getValueAsFieldType(type, value));
}
}
@@ -0,0 +1,56 @@
/*
* 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.engine;
import java.time.Instant;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesRequest;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesResult;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Top fifty duplicate tuples with permission-filtered membership drills.
*******************************************************************************/
public class DuplicateFinder
{
private final AnalysisBackend backend;
/*******************************************************************************
** Bind primitive operations.
*******************************************************************************/
public DuplicateFinder(AnalysisBackend backend)
{
this.backend = backend;
}
/*******************************************************************************
** Guard tuples before exposing or serializing them.
*******************************************************************************/
public DuplicatesResult find(DuplicatesRequest request, EffectiveConfig config) throws QException
{
AnalysisBackend.Scope scope = backend.scope(request.getTableName(), request.getFilter(), true);
return (new DuplicatesResult().withTableName(request.getTableName()).withFields(request.getFields()).withFilter(DrillFilters.copy(request.getFilter()))
.withRowsScanned(scope.rowsScanned()).withTruncated(scope.truncated()).withAsOf(Instant.now())
.withGroups(new MinGroupSizeGuard(config.getMinGroupSize()).groups(ProfileSupport.values(request.getTableName(), scope.filter(), null,
backend.duplicates(request.getTableName(), scope.filter(), request.getFields(), request.getLimit()), scope.rowsScanned()))));
}
}
@@ -0,0 +1,211 @@
/*
* 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.engine;
import java.io.Serializable;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed EffectiveConfig contract; independent of any presentation framework.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class EffectiveConfig implements Serializable
{
private Boolean enabled = true;
private Integer maxRows;
private Integer sampleThresholdRows;
private Integer sampleRows;
private Integer minGroupSize;
private Integer cacheTtlMinutes;
private Set<String> analyzableFields = Set.of();
private Set<String> excludedFields = Set.of();
/*******************************************************************************
** Getter for enabled.
*******************************************************************************/
public Boolean getEnabled()
{
return (enabled);
}
/*******************************************************************************
** Fluent setter for enabled.
*******************************************************************************/
public EffectiveConfig withEnabled(Boolean enabled)
{
this.enabled = enabled;
return (this);
}
/*******************************************************************************
** Getter for maxRows.
*******************************************************************************/
public Integer getMaxRows()
{
return (maxRows);
}
/*******************************************************************************
** Fluent setter for maxRows.
*******************************************************************************/
public EffectiveConfig withMaxRows(Integer maxRows)
{
this.maxRows = maxRows;
return (this);
}
/*******************************************************************************
** Getter for sampleThresholdRows.
*******************************************************************************/
public Integer getSampleThresholdRows()
{
return (sampleThresholdRows);
}
/*******************************************************************************
** Fluent setter for sampleThresholdRows.
*******************************************************************************/
public EffectiveConfig withSampleThresholdRows(Integer sampleThresholdRows)
{
this.sampleThresholdRows = sampleThresholdRows;
return (this);
}
/*******************************************************************************
** Getter for sampleRows.
*******************************************************************************/
public Integer getSampleRows()
{
return (sampleRows);
}
/*******************************************************************************
** Fluent setter for sampleRows.
*******************************************************************************/
public EffectiveConfig withSampleRows(Integer sampleRows)
{
this.sampleRows = sampleRows;
return (this);
}
/*******************************************************************************
** Getter for minGroupSize.
*******************************************************************************/
public Integer getMinGroupSize()
{
return (minGroupSize);
}
/*******************************************************************************
** Fluent setter for minGroupSize.
*******************************************************************************/
public EffectiveConfig withMinGroupSize(Integer minGroupSize)
{
this.minGroupSize = minGroupSize;
return (this);
}
/*******************************************************************************
** Getter for cacheTtlMinutes.
*******************************************************************************/
public Integer getCacheTtlMinutes()
{
return (cacheTtlMinutes);
}
/*******************************************************************************
** Fluent setter for cacheTtlMinutes.
*******************************************************************************/
public EffectiveConfig withCacheTtlMinutes(Integer cacheTtlMinutes)
{
this.cacheTtlMinutes = cacheTtlMinutes;
return (this);
}
/*******************************************************************************
** Getter for analyzableFields.
*******************************************************************************/
public Set<String> getAnalyzableFields()
{
return (analyzableFields);
}
/*******************************************************************************
** Fluent setter for analyzableFields.
*******************************************************************************/
public EffectiveConfig withAnalyzableFields(Set<String> analyzableFields)
{
this.analyzableFields = analyzableFields;
return (this);
}
/*******************************************************************************
** Getter for excludedFields.
*******************************************************************************/
public Set<String> getExcludedFields()
{
return (excludedFields);
}
/*******************************************************************************
** Fluent setter for excludedFields.
*******************************************************************************/
public EffectiveConfig withExcludedFields(Set<String> excludedFields)
{
this.excludedFields = excludedFields;
return (this);
}
}
@@ -0,0 +1,181 @@
/*
* 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.engine;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.TableAnalysisDefaults;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
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.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Instance-owned configuration resolver; only the settings lookup is privileged.
*******************************************************************************/
public class EffectiveConfigResolver
{
private final DataAnalysisQBitConfig defaults;
private final ConcurrentHashMap<String, CachedConfig> cache = new ConcurrentHashMap<>();
/*******************************************************************************
** A 60-second configuration snapshot, never a user-data cache.
*******************************************************************************/
private record CachedConfig(long expires, EffectiveConfig config)
{
}
/*******************************************************************************
** Bind code defaults to one host runtime.
*******************************************************************************/
public EffectiveConfigResolver(DataAnalysisQBitConfig defaults)
{
this.defaults = defaults;
}
/*******************************************************************************
** The code configuration this resolver starts from.
*******************************************************************************/
public DataAnalysisQBitConfig getDefaults()
{
return (defaults);
}
/*******************************************************************************
** Return a detached snapshot so callers cannot change memoized policy.
*******************************************************************************/
public EffectiveConfig resolve(String table) throws QException
{
CachedConfig cached = cache.get(table);
if(cached == null || cached.expires() <= System.currentTimeMillis())
{
cached = new CachedConfig(System.currentTimeMillis() + 60_000, load(table));
cache.put(table, cached);
}
EffectiveConfig c = cached.config();
return (new EffectiveConfig().withEnabled(c.getEnabled()).withMaxRows(c.getMaxRows()).withSampleRows(c.getSampleRows())
.withSampleThresholdRows(c.getSampleThresholdRows()).withMinGroupSize(c.getMinGroupSize()).withCacheTtlMinutes(c.getCacheTtlMinutes())
.withAnalyzableFields(Set.copyOf(c.getAnalyzableFields())).withExcludedFields(Set.copyOf(c.getExcludedFields())));
}
/*******************************************************************************
** Settings editors may invalidate immediately instead of waiting for expiry.
*******************************************************************************/
public void invalidate()
{
cache.clear();
}
/*******************************************************************************
** Runtime row overrides per-table code defaults, then global defaults.
*******************************************************************************/
private EffectiveConfig load(String table) throws QException
{
TableAnalysisDefaults d = defaults.getTableDefaults().getOrDefault(table, new TableAnalysisDefaults());
EffectiveConfig c = new EffectiveConfig().withEnabled(!Boolean.FALSE.equals(d.getEnabled()))
.withMaxRows(or(d.getMaxRows(), defaults.getDefaultMaxRows())).withSampleRows(or(d.getSampleRows(), defaults.getDefaultSampleRows()))
.withSampleThresholdRows(or(d.getSampleThresholdRows(), defaults.getDefaultSampleThresholdRows()))
.withMinGroupSize(or(d.getMinGroupSize(), defaults.getDefaultMinGroupSize())).withCacheTtlMinutes(or(d.getCacheTtlMinutes(), defaults.getDefaultCacheTtlMinutes()))
.withAnalyzableFields(d.getAnalyzableFields() == null ? Set.of() : Set.copyOf(d.getAnalyzableFields()))
.withExcludedFields(d.getExcludedFields() == null ? Set.of() : Set.copyOf(d.getExcludedFields()));
QSession original = QContext.getQSession();
List<QRecord> rows;
try
{
QContext.setQSession(new QSystemUserSession());
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG)
.withFilter(DrillFilters.condition("table_name", QCriteriaOperator.EQUALS, table).withLimit(1))
.withFieldNamesToInclude(Set.of("enabled", "max_rows", "sample_rows", "sample_threshold_rows", "min_group_size", "cache_ttl_minutes", "analyzable_fields", "excluded_fields"))
.withShouldFetchHeavyFields(true);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
rows = new QueryAction().execute(input).getRecords();
}
finally
{
QContext.setQSession(original);
}
if(!rows.isEmpty())
{
QRecord r = rows.getFirst();
if(r.getValue("enabled") != null)
{
c.withEnabled(r.getValueBoolean("enabled"));
}
c.withMaxRows(or(r.getValueInteger("max_rows"), c.getMaxRows())).withSampleRows(or(r.getValueInteger("sample_rows"), c.getSampleRows()))
.withSampleThresholdRows(or(r.getValueInteger("sample_threshold_rows"), c.getSampleThresholdRows()))
.withMinGroupSize(or(r.getValueInteger("min_group_size"), c.getMinGroupSize())).withCacheTtlMinutes(or(r.getValueInteger("cache_ttl_minutes"), c.getCacheTtlMinutes()));
if(r.getValue("analyzable_fields") != null)
{
c.withAnalyzableFields(csv(r.getValueString("analyzable_fields")));
}
if(r.getValue("excluded_fields") != null)
{
c.withExcludedFields(csv(r.getValueString("excluded_fields")));
}
}
c.withEnabled(c.getEnabled() && (defaults.getIncludeTables().isEmpty() || defaults.getIncludeTables().contains(table)) && !defaults.getExcludeTables().contains(table));
if(List.of(c.getMaxRows(), c.getSampleRows(), c.getSampleThresholdRows(), c.getMinGroupSize(), c.getCacheTtlMinutes()).stream().anyMatch(n -> n < 0))
{
throw new QException("Analysis settings contain an invalid limit.");
}
return (c);
}
/*******************************************************************************
** Null inherits, zero is a real configured limit.
*******************************************************************************/
private static Integer or(Integer value, Integer fallback)
{
return (value == null ? fallback : value);
}
/*******************************************************************************
** Blank lists mean all readable fields or no exclusions.
*******************************************************************************/
private static Set<String> csv(String text)
{
return (Arrays.stream(text.split(",")).map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toSet()));
}
}
@@ -0,0 +1,29 @@
/*
* 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.engine;
/*******************************************************************************
** Analysis access is stricter than the ability to submit an exact query.
*******************************************************************************/
public enum FieldAnalysisAccess
{
FULL, EXACT_MATCH_ONLY, NONE
}
@@ -0,0 +1,43 @@
/*
* 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.engine;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Resolve the current user's analysis rights without exposing protected values.
*******************************************************************************/
public interface FieldAnalysisAccessResolver
{
/*******************************************************************************
** Only FULL fields may contribute generated analysis predicates.
*******************************************************************************/
FieldAnalysisAccess resolve(QTableMetaData table, QFieldMetaData field);
/*******************************************************************************
** Optional core policy fingerprint supplements the FULL-field list hash.
*******************************************************************************/
default String getAccessFingerprint()
{
return ("");
}
}
@@ -0,0 +1,42 @@
/*
* 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.engine;
import com.kingsrook.qqq.backend.core.actions.permissions.FieldSecurityLockHelper;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Legacy lock adapter. The core tier API (qbit-secured-data) will map REVEALABLE
** to EXACT_MATCH_ONLY, MASKED/HIDDEN without filter rights to NONE, and
** MASKED/HIDDEN with filter rights to FULL.
*******************************************************************************/
public class FieldSecurityLockFieldAccessResolver implements FieldAnalysisAccessResolver
{
/*******************************************************************************
** Keep the legacy lock enforcement when core tiers are not installed.
*******************************************************************************/
@Override
public FieldAnalysisAccess resolve(QTableMetaData table, QFieldMetaData field)
{
return (FieldSecurityLockHelper.isFieldAccessible(field) ? FieldAnalysisAccess.FULL : FieldAnalysisAccess.NONE);
}
}
@@ -0,0 +1,67 @@
/*
* 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.engine;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
/*******************************************************************************
** One vocabulary for every emitted metric, including date-specific extrema.
*******************************************************************************/
public final class MetricLabels
{
private static final Map<String, String> LABELS = Map.ofEntries(
Map.entry("count", "Count"), Map.entry("delta", "Difference"), Map.entry("percentDelta", "Percent change"),
Map.entry("psi", "Population stability index"), Map.entry("rowShare", "Row share"), Map.entry("columnShare", "Column share"),
Map.entry("rows", "Rows"), Map.entry("nonNull", "Non-empty"), Map.entry("nulls", "Nulls"),
Map.entry("blanks", "Blanks"), Map.entry("distinct", "Distinct values"), Map.entry("uniqueness", "Uniqueness"),
Map.entry("completeness", "Completeness"), Map.entry("share", "Share"),
Map.entry("min", "Minimum"), Map.entry("max", "Maximum"), Map.entry("mean", "Mean"),
Map.entry("median", "Median"), Map.entry("stddev", "Standard deviation"), Map.entry("sum", "Sum"),
Map.entry("zeros", "Zeros"), Map.entry("negatives", "Negatives"), Map.entry("outliers", "Outliers"),
Map.entry("p1", "1st percentile"), Map.entry("p5", "5th percentile"), Map.entry("p25", "25th percentile (Q1)"),
Map.entry("p50", "Median"), Map.entry("p75", "75th percentile (Q3)"), Map.entry("p95", "95th percentile"), Map.entry("p99", "99th percentile"),
Map.entry("minLength", "Shortest"), Map.entry("maxLength", "Longest"), Map.entry("meanLength", "Average length"),
Map.entry("earliest", "Earliest"), Map.entry("latest", "Latest"), Map.entry("range", "Range"),
Map.entry("true", "True"), Map.entry("false", "False"), Map.entry("other", "Other"));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private MetricLabels()
{
}
/*******************************************************************************
** Fail at construction when a new metric lacks a human label.
*******************************************************************************/
public static String label(String key, ValueType type)
{
if(type == ValueType.DATE || type == ValueType.DATE_TIME)
{
key = "min".equals(key) ? "earliest" : "max".equals(key) ? "latest" : key;
}
return (Objects.requireNonNull(LABELS.get(key), "Missing metric label: " + key));
}
}
@@ -0,0 +1,158 @@
/*
* 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.engine;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.ValueCount;
/*******************************************************************************
** Privacy transformation before chart construction, serialization and caching.
*******************************************************************************/
public class MinGroupSizeGuard
{
private final int minimum;
/*******************************************************************************
** Zero disables suppression.
*******************************************************************************/
public MinGroupSizeGuard(int minimum)
{
this.minimum = minimum;
}
/*******************************************************************************
** Never carry original values or drills into a merged group.
*******************************************************************************/
public List<ValueCount> groups(List<ValueCount> groups)
{
if(minimum == 0)
{
return (groups);
}
List<ValueCount> result = new ArrayList<>();
long count = 0;
int hidden = 0;
java.math.BigDecimal share = java.math.BigDecimal.ZERO;
for(ValueCount group : groups)
{
if(group.getCount() < minimum)
{
hidden++;
count += group.getCount();
share = share.add(group.getShare());
}
else
{
result.add(group);
}
}
if(hidden > 0)
{
result.add(new ValueCount().withLabel("Other (" + hidden + " groups below " + minimum + ")").withCount(count).withShare(AnalysisFormatting.ratio(share)));
}
return (result);
}
/*******************************************************************************
** Suppressed scalar values are removed, not just tagged as hidden.
*******************************************************************************/
public void metrics(List<Metric> metrics, long rows)
{
if(rows >= minimum)
{
return;
}
for(Metric metric : metrics)
{
String key = metric.getKey();
if(key.startsWith("p") || key.startsWith("min") || key.startsWith("max") || key.startsWith("mean") || key.equals("stddev") || key.equals("range"))
{
metric.withValue(null).withFormatted(null).withDrill(null).withSuppressed(true)
.withSuppressedReason("Hidden: fewer than " + minimum + " records");
}
}
}
/*******************************************************************************
** Remove extreme values and their drills whenever privacy is enabled.
*******************************************************************************/
public void column(ColumnProfile profile, long rows)
{
profile.withTopValues(groups(profile.getTopValues()));
if(minimum > 0)
{
profile.getCounts().stream().filter(m -> m.getKey().equals("distinct")).forEach(m -> m.withDrill(null));
if(profile.getBooleans() != null)
{
List<ValueCount> booleanGroups = profile.getBooleans().stream().map(m -> new ValueCount().withValue(m.getKey()).withLabel(m.getLabel())
.withCount(((Number) m.getValue()).longValue()).withShare(ProfileSupport.share(((Number) m.getValue()).longValue(), rows)).withDrill(m.getDrill())).toList();
profile.withBooleans(groups(booleanGroups).stream().map(v -> ProfileSupport.metric(v.getValue() == null ? "other" : v.getValue().toString(), v.getCount(),
com.kingsrook.qbits.dataanalysis.result.ValueType.COUNT).withDrill(v.getDrill())).toList());
}
}
if(profile.getNumeric() != null)
{
metrics(profile.getNumeric().getMetrics(), rows);
profile.getNumeric().withHistogram(groups(profile.getNumeric().getHistogram()));
if(minimum > 0)
{
profile.getNumeric().withOutliers(List.of());
profile.getNumeric().getMetrics().stream().filter(m -> m.getKey().equals("outliers")).forEach(m -> m.withDrill(null));
}
}
if(profile.getDates() != null)
{
metrics(profile.getDates().getMetrics(), rows);
profile.getDates().withBuckets(groups(profile.getDates().getBuckets())).withDayOfWeek(groups(profile.getDates().getDayOfWeek()))
.withHourOfDay(groups(profile.getDates().getHourOfDay()));
if(minimum > 0)
{
profile.getDates().withGaps(List.of());
}
}
if(profile.getText() != null)
{
metrics(profile.getText().getMetrics(), rows);
profile.getText().withHistogram(groups(profile.getText().getHistogram())).withPatterns(groups(profile.getText().getPatterns()));
}
}
/*******************************************************************************
** Small pivot cells suppress every measure, including count, with no drill.
*******************************************************************************/
public void pivotMetrics(List<Metric> metrics, long rows)
{
if(rows < minimum)
{
metrics.forEach(m -> m.withValue(null).withFormatted(null).withDrill(null).withSuppressed(true)
.withSuppressedReason("Hidden: fewer than " + minimum + " records"));
}
}
}
@@ -0,0 +1,70 @@
/*
* 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.engine;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
/*******************************************************************************
** Exact decimal boundaries with a readable step and approximately twenty bins.
*******************************************************************************/
public record NiceBins(BigDecimal low, BigDecimal high, int count)
{
/*******************************************************************************
** Select the closest 10..25-bin enclosure from 1, 2, 2.5 and 5 times powers of ten.
*******************************************************************************/
public static NiceBins covering(BigDecimal low, BigDecimal high)
{
return (covering(low, high, 20));
}
/*******************************************************************************
** Choose readable enclosing edges for a requested pivot bin count.
*******************************************************************************/
public static NiceBins covering(BigDecimal low, BigDecimal high, int target)
{
BigDecimal span = high.subtract(low);
if(span.signum() == 0)
{
span = low.abs().max(BigDecimal.ONE);
low = low.subtract(span.divide(BigDecimal.valueOf(2)));
high = high.add(span.divide(BigDecimal.valueOf(2)));
}
int exponent = span.precision() - span.scale() - Integer.toString(target).length();
NiceBins best = null;
for(int power = exponent - 2; power <= exponent + 2; power++)
{
for(String factor : List.of("1", "2", "2.5", "5"))
{
BigDecimal step = new BigDecimal(factor).scaleByPowerOfTen(power);
BigDecimal first = low.divide(step, 0, RoundingMode.FLOOR).multiply(step);
BigDecimal last = high.divide(step, 0, RoundingMode.CEILING).multiply(step);
int count = last.subtract(first).divide(step).intValueExact();
if(count >= Math.max(1, target / 2) && count <= Math.max(2, target * 5 / 4) && (best == null || Math.abs(count - target) < Math.abs(best.count() - target)))
{
best = new NiceBins(first, last, count);
}
}
}
return (java.util.Objects.requireNonNull(best));
}
}
@@ -0,0 +1,641 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.PivotCell;
import com.kingsrook.qbits.dataanalysis.result.PivotDimension;
import com.kingsrook.qbits.dataanalysis.result.PivotHeader;
import com.kingsrook.qbits.dataanalysis.result.PivotMeasure;
import com.kingsrook.qbits.dataanalysis.result.PivotMeasureInfo;
import com.kingsrook.qbits.dataanalysis.result.PivotRequest;
import com.kingsrook.qbits.dataanalysis.result.PivotResult;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucket;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucketUnit;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.NumericBucket;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByAggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
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.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import com.kingsrook.qqq.backend.core.utils.aggregates.AggregateAnalysis;
/*******************************************************************************
** Request-local pivot planner. Core executes every aggregate with ENFORCE.
** Merged cells are re-aggregated so non-additive measures remain exact.
*******************************************************************************/
public class PivotExplorer
{
private final AnalysisSecurity security;
private final EffectiveConfig config;
private final List<QueryJoin> joins = new ArrayList<>();
private final LinkedHashSet<String> toManyTables = new LinkedHashSet<>();
private final LinkedHashSet<String> multiHopTables = new LinkedHashSet<>();
private PivotRequest request;
private Aggregate count;
private final List<Aggregate> measures = new ArrayList<>();
private final List<PivotMeasureInfo> measureInfo = new ArrayList<>();
private boolean partial;
/*******************************************************************************
** Bind the current user's policy and bounded streamed fallback.
*******************************************************************************/
public PivotExplorer(AnalysisSecurity security, EffectiveConfig config)
{
this.security = security;
this.config = config;
}
/*******************************************************************************
** One slot is a visible group or the exact complement of the kept groups.
*******************************************************************************/
private record Slot(Serializable value, String label, QQueryFilter condition, boolean other)
{
}
/*******************************************************************************
** Dimension state owns predicates, so no generated filter names hidden fields.
*******************************************************************************/
private record Dimension(GroupBy group, QFieldMetaData field, Map<Serializable, Slot> kept, Map<Serializable, Long> counts, Slot other)
{
/*******************************************************************************
** Return the kept group, Other, or null for a privacy-suppressed group.
*******************************************************************************/
Slot slot(Serializable value, int minimum)
{
return (counts.getOrDefault(value, 0L) < minimum ? null : kept.getOrDefault(value, other));
}
}
/*******************************************************************************
** Validate the complete plan before any source computation.
*******************************************************************************/
public PivotResult execute(PivotRequest request) throws QException
{
this.request = request;
validate();
String primaryKey = QContext.getQInstance().getTable(request.getTableName()).getPrimaryKeyField();
count = new Aggregate(primaryKey, AggregateOperator.COUNT).withFieldType(QFieldType.INTEGER);
List<PivotDimension> requested = new ArrayList<>(request.getRows());
requested.addAll(request.getColumns());
List<QFieldMetaData> fields = new ArrayList<>();
for(PivotDimension dimension : requested)
{
fields.add(resolve(dimension.getField()));
}
for(int i = 0; i < request.getValues().size(); i++)
{
prepareMeasure(request.getValues().get(i), i);
}
List<Dimension> dimensions = new ArrayList<>();
for(int i = 0; i < requested.size(); i++)
{
dimensions.add(dimension(requested.get(i), fields.get(i)));
}
int rowCount = request.getRows().size();
List<Dimension> rows = dimensions.subList(0, rowCount);
List<Dimension> columns = dimensions.subList(rowCount, dimensions.size());
PivotResult result = new PivotResult().withAsOf(Instant.now()).withMeasures(measureInfo)
.withTruncatedRows(rows.stream().anyMatch(d -> d.other() != null))
.withTruncatedColumns(columns.stream().anyMatch(d -> d.other() != null));
Map<List<Slot>, List<Metric>> matrix = level(dimensions);
Map<List<Slot>, List<Metric>> rowTotals = request.getShowTotals() || config.getMinGroupSize() > 0 ? level(rows) : Map.of();
Map<List<Slot>, List<Metric>> columnTotals = request.getShowTotals() || config.getMinGroupSize() > 0 ? level(columns) : Map.of();
List<List<Slot>> rowKeys = new ArrayList<>();
List<List<Slot>> columnKeys = new ArrayList<>();
for(var entry : matrix.entrySet())
{
List<Slot> row = List.copyOf(entry.getKey().subList(0, rowCount));
List<Slot> column = List.copyOf(entry.getKey().subList(rowCount, dimensions.size()));
if(config.getMinGroupSize() > 0 && (rowTotals.getOrDefault(row, List.of()).stream().allMatch(m -> m.getSuppressed())
|| columnTotals.getOrDefault(column, List.of()).stream().allMatch(m -> m.getSuppressed())))
{
continue;
}
if(!rowKeys.contains(row))
{
rowKeys.add(row);
}
if(!columnKeys.contains(column))
{
columnKeys.add(column);
}
result.getCells().add(new PivotCell().withRowIndex(rowKeys.indexOf(row)).withColumnIndex(columnKeys.indexOf(column)).withValues(entry.getValue()));
}
result.withRowHeaders(headers(rowKeys)).withColumnHeaders(headers(columnKeys));
if(request.getShowTotals())
{
rowKeys.forEach(key -> result.getRowTotals().add(rowTotals.getOrDefault(key, List.of())));
columnKeys.forEach(key -> result.getColumnTotals().add(columnTotals.getOrDefault(key, List.of())));
result.withGrandTotals(level(List.of()).getOrDefault(List.of(), List.of()));
}
for(String tableName : toManyTables)
{
result.getWarnings().add("Counts include one row per related " + QContext.getQInstance().getTable(tableName).getLabel()
+ "; drill-through is not available for this dimension.");
}
for(String tableName : multiHopTables)
{
result.getWarnings().add("Drill-through is not available for fields reached through more than one join (" + QContext.getQInstance().getTable(tableName).getLabel() + ").");
}
if(config.getMinGroupSize() > 0)
{
result.getWarnings().add("Groups below " + config.getMinGroupSize() + " records are omitted; small cells and totals are suppressed.");
}
if(partial)
{
result.withEstimate(true);
result.getWarnings().add("Partial pivot: the streamed row limit was reached. Drills are disabled because aggregates may cover different prefixes.");
result.getCells().forEach(c -> disableDrills(c.getValues()));
result.getRowTotals().forEach(this::disableDrills);
result.getColumnTotals().forEach(this::disableDrills);
disableDrills(result.getGrandTotals());
}
return (result);
}
/*******************************************************************************
** Reject invalid shapes and types before aggregate execution.
*******************************************************************************/
private void validate() throws QException
{
if(request.getRows() == null || request.getRows().isEmpty() || request.getRows().size() > 3
|| request.getColumns() == null || request.getColumns().size() > 2 || request.getValues() == null
|| request.getValues().isEmpty() || request.getValues().size() > 6 || request.getShowTotals() == null
|| request.getLimitGroups() == null || request.getLimitGroups() < 1 || request.getLimitGroups() > 200)
{
throw new QBadRequestException("Choose 1�3 row dimensions, 0�2 column dimensions, 1�6 measures and a group limit from 1 to 200.");
}
List<PivotDimension> dimensions = new ArrayList<>(request.getRows());
dimensions.addAll(request.getColumns());
LinkedHashSet<String> names = new LinkedHashSet<>();
for(PivotDimension dimension : dimensions)
{
if(dimension == null || dimension.getField() == null || !names.add(dimension.getField()))
{
throw new QBadRequestException("Choose distinct dimension fields.");
}
QFieldMetaData field = resolve(dimension.getField());
var bins = dimension.getNumericBins();
if(dimension.getDateBucket() != null && (bins != null || field.getType() != QFieldType.DATE && field.getType() != QFieldType.DATE_TIME)
|| bins != null && (!numeric(field) || bins.getCount() == null || bins.getCount() < 1 || bins.getCount() > 200
|| bins.getMin() != null && bins.getMax() != null && bins.getMin().compareTo(bins.getMax()) >= 0))
{
throw new QBadRequestException("Buckets must match the field type; numeric bins require 1�200 bins and min < max.");
}
}
}
/*******************************************************************************
** Only exposed, unambiguous paths may introduce a joined field.
** The installed snapshot has no related-record picker join-path helper.
*******************************************************************************/
private QFieldMetaData resolve(String name) throws QException
{
if(name == null || !name.contains("."))
{
return (security.field(request.getTableName(), name));
}
String[] parts = name.split("\\.", -1);
var table = QContext.getQInstance().getTable(request.getTableName());
List<ExposedJoin> exposed = table.getExposedJoins() == null ? List.of() : table.getExposedJoins();
List<ExposedJoin> paths = exposed.stream().filter(j -> j.getJoinTable().equals(parts[0])).toList();
if(parts.length != 2 || paths.size() != 1 || paths.getFirst().getJoinPath() == null || paths.getFirst().getJoinPath().isEmpty())
{
throw new QBadRequestException("Unknown analysis field.");
}
String current = table.getName();
boolean toOne = true;
for(String joinName : paths.getFirst().getJoinPath())
{
var metadata = QContext.getQInstance().getJoin(joinName);
if(metadata == null || !current.equals(metadata.getLeftTable()) && !current.equals(metadata.getRightTable()))
{
throw new QBadRequestException("Unknown analysis field.");
}
String next = current.equals(metadata.getLeftTable()) ? metadata.getRightTable() : metadata.getLeftTable();
JoinType type = metadata.getType();
if(type != null && current.equals(metadata.getRightTable()))
{
type = type.flip();
}
toOne &= type == JoinType.MANY_TO_ONE || type == JoinType.ONE_TO_ONE;
security.joinedTable(next);
QueryJoin existing = joins.stream().filter(j -> next.equals(j.getJoinTable())).findFirst().orElse(null);
if(existing == null)
{
joins.add(new QueryJoin(current, next).withJoinMetaData(metadata));
}
else if(!existing.getJoinMetaData().getName().equals(joinName))
{
throw new QBadRequestException("Ambiguous analysis join path.");
}
current = next;
}
if(!current.equals(parts[0]))
{
throw new QBadRequestException("Unknown analysis field.");
}
if(!toOne)
{
toManyTables.add(current);
}
else if(paths.getFirst().getJoinPath().size() > 1)
{
///////////////////////////////////////////////////////////////////////////////
// counts are per record, but core's implicit joins for a multi-hop filter //
// do not reproduce them on every backend yet (memory counts differ), so no //
// drill until that is fixed in core //
///////////////////////////////////////////////////////////////////////////////
multiHopTables.add(current);
}
return (security.joinedField(parts[0], parts[1]));
}
/*******************************************************************************
** Count records even when a supplied COUNT field is nullable.
*******************************************************************************/
private void prepareMeasure(PivotMeasure measure, int index) throws QException
{
if(measure == null || measure.getOperator() == null)
{
throw new QBadRequestException("Choose a measure operator.");
}
QFieldMetaData field = measure.getField() == null && measure.getOperator() == PivotMeasure.Operator.COUNT ? null : resolve(measure.getField());
PivotMeasure.Operator operator = measure.getOperator();
if((operator == PivotMeasure.Operator.SUM || operator == PivotMeasure.Operator.AVG || operator == PivotMeasure.Operator.PERCENTILE) && !numeric(field)
|| operator == PivotMeasure.Operator.PERCENTILE && (measure.getPercentile() == null || measure.getPercentile().signum() < 0 || measure.getPercentile().compareTo(BigDecimal.ONE) > 0)
|| operator != PivotMeasure.Operator.PERCENTILE && measure.getPercentile() != null)
{
throw new QBadRequestException("Numeric measures require a numeric field; percentile must be between 0 and 1.");
}
Aggregate aggregate = operator == PivotMeasure.Operator.COUNT ? count : new Aggregate(measure.getField(),
operator == PivotMeasure.Operator.PERCENTILE ? AggregateOperator.PERCENTILE_CONT : AggregateOperator.valueOf(operator.name()));
if(operator == PivotMeasure.Operator.PERCENTILE)
{
aggregate.withPercentile(measure.getPercentile());
}
if(operator == PivotMeasure.Operator.COUNT_DISTINCT)
{
aggregate.withFieldType(QFieldType.INTEGER);
}
measures.add(aggregate);
ValueType type = operator == PivotMeasure.Operator.COUNT || operator == PivotMeasure.Operator.COUNT_DISTINCT ? ValueType.COUNT
: numeric(field) ? ValueType.DECIMAL : field.getType() == QFieldType.DATE ? ValueType.DATE : field.getType() == QFieldType.DATE_TIME ? ValueType.DATE_TIME : ValueType.TEXT;
measureInfo.add(new PivotMeasureInfo().withKey("value" + index).withLabel(measureLabel(measure, field)).withValueType(type));
}
/*******************************************************************************
** Human measure labels shared by headers, totals and cells.
*******************************************************************************/
private static String measureLabel(PivotMeasure measure, QFieldMetaData field)
{
String label = field == null ? "" : field.getLabel();
return (switch(measure.getOperator())
{
case COUNT -> "Count";
case AVG -> "Average " + label;
case SUM -> "Sum of " + label;
case COUNT_DISTINCT -> "Distinct " + label;
case MIN -> "Minimum " + label;
case MAX -> "Maximum " + label;
case PERCENTILE -> measure.getPercentile().compareTo(new BigDecimal("0.5")) == 0 ? "Median " + label
: ordinal(measure.getPercentile().multiply(BigDecimal.valueOf(100))) + " percentile of " + label;
});
}
/*******************************************************************************
** English ordinal suffixes, including the eleventh through thirteenth.
*******************************************************************************/
private static String ordinal(BigDecimal value)
{
int number = value.intValue();
String suffix = number % 100 >= 11 && number % 100 <= 13 ? "th" : switch(number % 10)
{
case 1 -> "st";
case 2 -> "nd";
case 3 -> "rd";
default -> "th";
};
return (value.stripTrailingZeros().toPlainString() + suffix);
}
/*******************************************************************************
** Numeric buckets and moments accept the same numeric QQQ field types.
*******************************************************************************/
private static boolean numeric(QFieldMetaData field)
{
return (field != null && (field.getType() == QFieldType.INTEGER || field.getType() == QFieldType.LONG || field.getType() == QFieldType.DECIMAL));
}
/*******************************************************************************
** Select top groups first, then collect cardinality and privacy counts.
*******************************************************************************/
private Dimension dimension(PivotDimension requested, QFieldMetaData field) throws QException
{
GroupBy group = new GroupBy(field.getType(), requested.getField());
if(requested.getDateBucket() != null)
{
group.withBucket(new DateBucket(requested.getDateBucket()));
}
if(requested.getNumericBins() != null)
{
var bins = requested.getNumericBins();
BigDecimal low = bins.getMin();
BigDecimal high = bins.getMax();
if(low == null || high == null)
{
Aggregate min = new Aggregate(requested.getField(), AggregateOperator.MIN);
Aggregate max = new Aggregate(requested.getField(), AggregateOperator.MAX);
var extrema = aggregate(List.of(), request.getFilter(), List.of(min, max), null);
Serializable first = extrema.isEmpty() ? null : extrema.getFirst().getAggregateValues().get(min);
Serializable last = extrema.isEmpty() ? null : extrema.getFirst().getAggregateValues().get(max);
NiceBins nice = NiceBins.covering(first == null ? BigDecimal.ZERO : new BigDecimal(first.toString()), last == null ? BigDecimal.ONE : new BigDecimal(last.toString()), bins.getCount());
low = low == null ? nice.low() : low;
high = high == null ? nice.high() : high;
}
if(low.compareTo(high) >= 0)
{
throw new QBadRequestException("Numeric bin min must be below max.");
}
group.withBucket(new NumericBucket(low, high, bins.getCount()));
}
List<AggregateResult> top = aggregate(List.of(group), request.getFilter(), List.of(count), request.getLimitGroups());
List<AggregateResult> all = aggregate(List.of(group), request.getFilter(), List.of(count), null);
Map<Serializable, Long> counts = new LinkedHashMap<>();
all.forEach(r -> counts.put(r.getGroupByValues().get(group), count(r)));
Map<Serializable, Slot> kept = new LinkedHashMap<>();
for(AggregateResult row : top)
{
Serializable value = row.getGroupByValues().get(group);
if(count(row) >= config.getMinGroupSize())
{
kept.put(value, new Slot(value, label(group, field, value), condition(group, field, value), false));
}
}
List<Serializable> remaining = counts.keySet().stream().filter(v -> !kept.containsKey(v) && counts.get(v) >= config.getMinGroupSize()).toList();
Slot other = null;
if(!remaining.isEmpty())
{
QQueryFilter predicate;
if(group.getBucket() == null && field.getType() != QFieldType.BOOLEAN && config.getMinGroupSize() == 0 && !kept.containsKey("") && kept.keySet().stream().noneMatch(v -> v instanceof Instant || v instanceof LocalDate))
{
List<Serializable> values = kept.keySet().stream().filter(Objects::nonNull).toList();
predicate = values.isEmpty() ? new QQueryFilter() : new QQueryFilter().withCriteria(group.getFieldName(), QCriteriaOperator.NOT_IN, values);
predicate = DrillFilters.and(predicate, DrillFilters.nonNull(group.getFieldName(), field.getType().isStringLike()));
if(!kept.containsKey(null))
{
predicate = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(predicate).withSubFilter(DrillFilters.nulls(group.getFieldName(), field.getType().isStringLike()));
}
if(field.getType().isStringLike())
{
predicate = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(predicate).withSubFilter(DrillFilters.value(group.getFieldName(), "", true));
}
}
else
{
predicate = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
for(Serializable value : remaining)
{
predicate.withSubFilter(condition(group, field, value));
}
}
other = new Slot(null, "Other (" + remaining.size() + " groups)", predicate, true);
}
return (new Dimension(group, field, kept, counts, other));
}
/*******************************************************************************
** Native group keys are displayed as labels without changing filter operands.
*******************************************************************************/
private String label(GroupBy group, QFieldMetaData field, Serializable value) throws QException
{
if(value == null)
{
return ("(Blank)");
}
if(group.getBucket() instanceof DateBucket date)
{
return (AnalysisFormatting.dateBucket(value, AnalysisBackend.DateUnit.valueOf(date.getUnit().name()), ValueUtils.getSessionOrInstanceZoneId()));
}
if(group.getBucket() instanceof NumericBucket bins)
{
BigDecimal[] edges = edges(bins, ((Number) value).intValue());
return (AnalysisFormatting.interval(field, edges[0], edges[1]));
}
return (security.label(field, value));
}
/*******************************************************************************
** Match core's final closed numeric bin and exterior bins.
*******************************************************************************/
private static BigDecimal[] edges(NumericBucket bins, int index)
{
BigDecimal width = bins.getMax().subtract(bins.getMin()).divide(BigDecimal.valueOf(bins.getCount()), MathContext.DECIMAL128);
return (new BigDecimal[] { index == 0 ? null : index > bins.getCount() ? bins.getMax() : bins.getMin().add(width.multiply(BigDecimal.valueOf(index - 1))),
index == 0 ? bins.getMin() : index > bins.getCount() ? null : index == bins.getCount() ? bins.getMax() : bins.getMin().add(width.multiply(BigDecimal.valueOf(index))) });
}
/*******************************************************************************
** Calendar and numeric predicates use core bucket boundaries in the session zone.
*******************************************************************************/
private QQueryFilter condition(GroupBy group, QFieldMetaData field, Serializable value) throws QException
{
String name = group.getFieldName();
if(value == null || group.getBucket() == null)
{
return (DrillFilters.value(name, value, field.getType().isStringLike()));
}
if(group.getBucket() instanceof NumericBucket bins)
{
int index = ((Number) value).intValue();
BigDecimal[] edges = edges(bins, index);
if(StandInAnalysisBackend.isMemory(request.getTableName()) && index <= bins.getCount())
{
GroupBy raw = new GroupBy(field.getType(), name);
List<BigDecimal> members = aggregate(List.of(raw), request.getFilter(), List.of(count), null).stream()
.map(r -> r.getGroupByValues().get(raw)).filter(Objects::nonNull).map(v -> new BigDecimal(v.toString())).toList();
return (new StandInAnalysisBackend(config).numericRange(request.getTableName(), name, members, edges[0], edges[1], index == bins.getCount()));
}
return (index > bins.getCount() ? DrillFilters.condition(name, QCriteriaOperator.GREATER_THAN, bins.getMax())
: DrillFilters.range(name, edges[0], edges[1], index == bins.getCount()));
}
DateBucketUnit unit = ((DateBucket) group.getBucket()).getUnit();
if(unit == DateBucketUnit.HOUR)
{
Instant start = ValueUtils.getValueAsInstant(value);
return (DrillFilters.range(name, start, start.plusSeconds(3600), false));
}
if(unit == DateBucketUnit.DAY_OF_WEEK || unit == DateBucketUnit.HOUR_OF_DAY)
{
GroupBy raw = new GroupBy(field.getType(), name);
List<Serializable> members = aggregate(List.of(raw), request.getFilter(), List.of(count), null).stream().map(r -> r.getGroupByValues().get(raw))
.filter(v -> Objects.equals(AggregateAnalysis.bucket(v, group.getBucket(), ValueUtils.getSessionOrInstanceZoneId()), value)).toList();
return (DrillFilters.in(name, members));
}
LocalDate start = ValueUtils.getValueAsLocalDate(value);
LocalDate end = switch(unit)
{
case YEAR -> start.plusYears(1);
case QUARTER -> start.plusMonths(3);
case MONTH -> start.plusMonths(1);
case WEEK -> start.plusWeeks(1);
default -> start.plusDays(1);
};
return (DrillFilters.dateRange(name, start, end, field.getType() == QFieldType.DATE_TIME, ValueUtils.getSessionOrInstanceZoneId()));
}
/*******************************************************************************
** A level is the full matrix, row totals, column totals or grand totals.
*******************************************************************************/
private Map<List<Slot>, List<Metric>> level(List<Dimension> dimensions) throws QException
{
List<Aggregate> requested = new ArrayList<>(measures);
if(!requested.contains(count))
{
requested.add(count);
}
Map<List<Slot>, List<AggregateResult>> grouped = new LinkedHashMap<>();
List<AggregateResult> aggregates = aggregate(dimensions.stream().map(Dimension::group).toList(), request.getFilter(), requested, null);
if(dimensions.isEmpty() && aggregates.isEmpty())
{
AggregateResult empty = new AggregateResult();
requested.forEach(a -> empty.withAggregateValue(a, a.getOperator() == AggregateOperator.COUNT || a.getOperator() == AggregateOperator.COUNT_DISTINCT ? 0L : null));
aggregates = List.of(empty);
}
for(AggregateResult row : aggregates)
{
List<Slot> key = new ArrayList<>();
for(Dimension dimension : dimensions)
{
key.add(dimension.slot(row.getGroupByValues().get(dimension.group()), config.getMinGroupSize()));
}
if(!key.contains(null))
{
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(row);
}
}
Map<List<Slot>, List<Metric>> result = new LinkedHashMap<>();
for(var entry : grouped.entrySet())
{
QQueryFilter predicate = new QQueryFilter();
entry.getKey().forEach(slot -> predicate.withSubFilter(slot.condition()));
AggregateResult row = entry.getValue().getFirst();
if(entry.getKey().stream().anyMatch(Slot::other))
{
var merged = aggregate(List.of(), DrillFilters.and(request.getFilter(), predicate), requested, null);
if(merged.isEmpty())
{
continue;
}
row = merged.getFirst();
}
List<Metric> values = new ArrayList<>();
for(int i = 0; i < measures.size(); i++)
{
PivotMeasureInfo info = measureInfo.get(i);
Serializable value = row.getAggregateValues().get(measures.get(i));
Metric metric = new Metric().withKey(info.getKey()).withLabel(info.getLabel()).withValueType(info.getValueType()).withValue(value)
.withFormatted(AnalysisFormatting.scalar(info.getKey(), value, info.getValueType()));
if(toManyTables.isEmpty() && multiHopTables.isEmpty())
{
metric.withDrill(DrillFilters.drill(request.getTableName(), request.getFilter(), predicate));
}
values.add(metric);
}
new MinGroupSizeGuard(config.getMinGroupSize()).pivotMetrics(values, count(row));
result.put(entry.getKey(), values);
}
return (result);
}
/*******************************************************************************
** Count is always present, even when the user selects only non-count measures.
*******************************************************************************/
private long count(AggregateResult row)
{
Object value = row.getAggregateValues().get(count);
return (value == null ? 0 : ((Number) value).longValue());
}
/*******************************************************************************
** Core owns SQL pushdown, joins, record locks, field locks and bounded fallback.
*******************************************************************************/
private List<AggregateResult> aggregate(List<GroupBy> groups, QQueryFilter filter, List<Aggregate> aggregates, Integer limit) throws QException
{
QQueryFilter query = DrillFilters.restoreTypes(request.getTableName(), filter);
if(limit != null)
{
query.withOrderBy(new QFilterOrderByAggregate(count, false));
}
AggregateInput input = new AggregateInput(request.getTableName()).withFilter(query).withQueryJoins(joins.stream().map(QueryJoin::clone).toList())
.withAllowStreamedFallback(true).withStreamedRowLimit(config.getMaxRows()).withLimit(limit);
groups.forEach(input::withGroupBy);
new LinkedHashSet<>(aggregates).forEach(input::withAggregate);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
var output = com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.aggregate(input);
partial |= output.getTruncated();
return (limit == null ? output.getResults() : output.getResults().stream().sorted(java.util.Comparator.comparingLong(this::count).reversed()
.thenComparing(r -> String.valueOf(r.getGroupByValues().values()))).limit(limit).toList());
}
/*******************************************************************************
** Header tuples retain typed values; Other is distinguished by its label.
*******************************************************************************/
private static List<List<PivotHeader>> headers(List<List<Slot>> keys)
{
return (keys.stream().map(key -> key.stream().map(slot -> new PivotHeader().withValue(slot.value()).withLabel(slot.label())).toList()).toList());
}
/*******************************************************************************
** Partial numbers must never claim an exact drill equivalence.
*******************************************************************************/
private void disableDrills(List<Metric> metrics)
{
metrics.forEach(m -> m.withDrill(null).withEstimate(true));
}
}
@@ -0,0 +1,182 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.result.Axis;
import com.kingsrook.qbits.dataanalysis.result.Chart;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.Point;
import com.kingsrook.qbits.dataanalysis.result.Series;
import com.kingsrook.qbits.dataanalysis.result.ValueCount;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
/*******************************************************************************
** Shared semantic result construction; profilers contain no rendering concerns.
*******************************************************************************/
public final class ProfileSupport
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ProfileSupport()
{
}
/*******************************************************************************
** Empty populations have zero share, never NaN.
*******************************************************************************/
public static BigDecimal share(long count, long rows)
{
return (rows == 0 ? BigDecimal.ZERO : BigDecimal.valueOf(count).divide(BigDecimal.valueOf(rows), 6, java.math.RoundingMode.HALF_UP).stripTrailingZeros());
}
/*******************************************************************************
** Every ordinary count carries its exact membership predicate.
*******************************************************************************/
public static Metric count(String key, long count, String table, QQueryFilter filter, QQueryFilter condition)
{
return (metric(key, count, ValueType.COUNT).withDrill(DrillFilters.drill(table, filter, condition)));
}
/*******************************************************************************
** Scalars have semantic types and locale-neutral formatted values.
*******************************************************************************/
public static Metric metric(String key, Serializable value, ValueType type)
{
if(type == ValueType.PERCENT && value != null)
{
value = AnalysisFormatting.ratio(new BigDecimal(value.toString()));
}
return (new Metric().withKey(key).withLabel(MetricLabels.label(key, type)).withValue(value).withValueType(type)
.withFormatted(AnalysisFormatting.scalar(key, value, type)));
}
/*******************************************************************************
** The population row total is exact even when other metrics are estimates.
*******************************************************************************/
public static void markEstimates(List<Metric> metrics)
{
if(metrics != null)
{
metrics.stream().filter(m -> !"rows".equals(m.getKey())).forEach(m ->
{
m.withEstimate(true);
if(m.getFormatted() != null)
{
m.withFormatted("≈ " + m.getFormatted());
}
});
}
}
/*******************************************************************************
** PVS translations run as the current user; drill values always remain ids.
*******************************************************************************/
public static List<ValueCount> values(String table, QQueryFilter filter, QFieldMetaData field, List<AnalysisBackend.GroupCount> groups, long total) throws QException
{
return (values(table, filter, field, groups, total, CoreTierFieldAccessResolver.defaultResolver()));
}
/*******************************************************************************
** Host field policy applies to related display labels as well as source values.
*******************************************************************************/
public static List<ValueCount> values(String table, QQueryFilter filter, QFieldMetaData field, List<AnalysisBackend.GroupCount> groups, long total,
FieldAnalysisAccessResolver access) throws QException
{
List<ValueCount> result = new ArrayList<>();
for(AnalysisBackend.GroupCount group : groups)
{
String label = group.label();
if(field != null && field.getPossibleValueSourceName() != null && group.value() != null)
{
String translated = AnalysisLabels.translate(field, group.value(), access);
if(translated != null)
{
label = translated;
}
}
result.add(new ValueCount().withValue(group.value()).withLabel(label).withCount(group.count()).withShare(group.share() == null ? share(group.count(), total) : AnalysisFormatting.ratio(group.share()))
.withDrill(DrillFilters.drill(table, filter, group.condition())));
}
return (result);
}
/*******************************************************************************
** Charts are built only from privacy-guarded groups.
*******************************************************************************/
public static Chart chart(ChartKind kind, ChartKind alternative, String field, List<ValueCount> values)
{
Series series = new Series().withName("Count");
for(ValueCount value : values)
{
series.getPoints().add(new Point().withX(value.getValue()).withY(BigDecimal.valueOf(value.getCount()))
.withLabel(value.getLabel()).withDrill(value.getDrill()));
}
Chart chart = new Chart().withKind(kind).withAlternatives(List.of(alternative))
.withX(new Axis().withField(field).withLabel(field).withValueType(ValueType.TEXT))
.withY(new Axis().withLabel("Count").withValueType(ValueType.COUNT)).withSeries(new ArrayList<>(List.of(series)));
if(kind == ChartKind.TIME_SERIES)
{
Series average = new Series().withName("7-bucket moving average");
for(int i = 0; i < values.size(); i++)
{
long sum = 0;
for(int j = Math.max(0, i - 6); j <= i; j++)
{
sum += values.get(j).getCount();
}
average.getPoints().add(new Point().withX(values.get(i).getValue()).withLabel(values.get(i).getLabel())
.withY(BigDecimal.valueOf(sum).divide(BigDecimal.valueOf(Math.min(7, i + 1)), MathContext.DECIMAL128)));
}
chart.getSeries().add(average);
}
return (chart);
}
/*******************************************************************************
** Convert instants in the session zone; DATE values are already local dates.
*******************************************************************************/
public static java.time.LocalDate day(Serializable value, java.time.ZoneId zone)
{
return (value instanceof java.time.LocalDate date ? date : ((java.time.Instant) value).atZone(zone).toLocalDate());
}
}
@@ -0,0 +1,255 @@
/*
* 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.engine;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.CorrelationCell;
import com.kingsrook.qbits.dataanalysis.result.CorrelationRequest;
import com.kingsrook.qbits.dataanalysis.result.CorrelationResult;
import com.kingsrook.qbits.dataanalysis.result.CrosstabCell;
import com.kingsrook.qbits.dataanalysis.result.CrosstabRequest;
import com.kingsrook.qbits.dataanalysis.result.CrosstabResult;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.PivotHeader;
import com.kingsrook.qbits.dataanalysis.result.Point;
import com.kingsrook.qbits.dataanalysis.result.ScatterRequest;
import com.kingsrook.qbits.dataanalysis.result.ScatterResult;
import com.kingsrook.qbits.dataanalysis.result.Series;
import com.kingsrook.qbits.dataanalysis.result.TrendLine;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qbits.dataanalysis.stats.Statistics;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Pairwise correlation, bounded scatter samples and guarded contingency tables.
*******************************************************************************/
public class RelationshipExplorer extends RelationshipSupport
{
/*******************************************************************************
** Each operation has its own execution state and policy snapshot.
*******************************************************************************/
public RelationshipExplorer(AnalysisSecurity security, EffectiveConfig config, String table)
{
super(security, config, table);
}
/*******************************************************************************
** All unique CORR pairs share one aggregate call, including streamed fallback.
** Pair counts use core CountAction because Aggregate has no per-measure filter.
*******************************************************************************/
public CorrelationResult correlation(CorrelationRequest request) throws QException
{
List<String> fields = request.getFields();
if(fields == null || fields.size() < 2 || fields.size() > 12 || new LinkedHashSet<>(fields).size() != fields.size())
{
throw new QBadRequestException("Choose 2 to 12 distinct numeric fields.");
}
for(String field : fields)
{
requireNumeric(field);
}
List<Aggregate> aggregates = new ArrayList<>();
for(int i = 0; i < fields.size(); i++)
{
for(int j = i; j < fields.size(); j++)
{
aggregates.add(measure(fields.get(i), AggregateOperator.CORR).withOtherFieldName(fields.get(j)));
}
}
var values = summarize(request.getFilter(), aggregates).getAggregateValues();
CorrelationResult result = new CorrelationResult().withFields(List.copyOf(fields));
List<Series> series = new ArrayList<>();
for(int i = 0; i < fields.size(); i++)
{
String x = fields.get(i);
Series row = new Series().withName(x);
for(int j = 0; j < fields.size(); j++)
{
String y = fields.get(j);
Aggregate key = measure(fields.get(Math.min(i, j)), AggregateOperator.CORR).withOtherFieldName(fields.get(Math.max(i, j)));
BigDecimal value = decimal(values.get(key));
long n = size(paired(request.getFilter(), x, y));
boolean hidden = n < config.getMinGroupSize();
Double r = hidden || n < 2 || value == null ? null : value.doubleValue();
result.getCells().add(new CorrelationCell().withXField(x).withYField(y).withN(hidden ? null : n).withR(r)
.withScatter(hidden ? null : new ScatterRequest().withTableName(table).withXField(x).withYField(y).withFilter(DrillFilters.copy(request.getFilter()))));
row.getPoints().add(new Point().withX(y).withY(r == null ? null : BigDecimal.valueOf(r)).withLabel(x + " / " + y));
}
series.add(row);
}
return (result.withChart(chart(ChartKind.HEATMAP, series, "Pearson r; each cell includes its paired non-null count and scatter request.")));
}
/*******************************************************************************
** QueryInput has no sampling API. Fetch the first N paired records by primary
** key as the user, with core-enforced record locks and a maximum of 2,000.
*******************************************************************************/
public ScatterResult scatter(ScatterRequest request) throws QException
{
requireNumeric(request.getXField());
requireNumeric(request.getYField());
if(request.getLimit() == null || request.getLimit() < 1 || request.getLimit() > 2000)
{
throw new QBadRequestException("Scatter limit must be from 1 to 2000.");
}
String x = request.getXField();
String y = request.getYField();
String pk = primaryKey(table);
QQueryFilter filter = paired(request.getFilter(), x, y);
long n = size(filter);
ScatterResult result = new ScatterResult().withN(n).withSampling("First N paired records by primary key").withSampleSize(0);
if(n < config.getMinGroupSize())
{
return (result.withN(null).withChart(chart(ChartKind.SCATTER, List.of(), "Hidden: fewer than " + config.getMinGroupSize() + " paired records.")));
}
Set<String> selected = new LinkedHashSet<>(List.of(x, y));
boolean drill = config.getMinGroupSize() == 0 && security.analyzableFields(table).stream().anyMatch(f -> f.getName().equals(pk));
if(drill)
{
selected.add(pk);
}
var records = rows(table, filter, selected, request.getLimit());
double[] xs = new double[records.size()];
double[] ys = new double[records.size()];
for(int i = 0; i < records.size(); i++)
{
var record = records.get(i);
BigDecimal xv = decimal(record.getValue(x));
BigDecimal yv = decimal(record.getValue(y));
xs[i] = xv.doubleValue();
ys[i] = yv.doubleValue();
result.getPoints().add(new Point().withX(xv).withY(yv).withDrill(drill ? DrillFilters.drill(table, null,
DrillFilters.condition(pk, QCriteriaOperator.EQUALS, record.getValue(pk))) : null));
}
result.withSampleSize(records.size()).withTrend(trend(xs, ys));
Series points = new Series().withName("Sample").withPoints(result.getPoints());
Series line = new Series().withName("Least-squares trend");
if(result.getTrend() != null)
{
double low = java.util.Arrays.stream(xs).min().orElseThrow();
double high = java.util.Arrays.stream(xs).max().orElseThrow();
for(double value : new double[] {low, high})
{
line.getPoints().add(new Point().withX(BigDecimal.valueOf(value)).withY(BigDecimal.valueOf(result.getTrend().getIntercept() + result.getTrend().getSlope() * value)));
}
}
return (result.withChart(chart(ChartKind.SCATTER, List.of(points, line), "Trend describes the displayed sample, not the full population.")));
}
/*******************************************************************************
** Centered least squares remains stable when coordinates have large offsets.
*******************************************************************************/
private TrendLine trend(double[] x, double[] y)
{
if(x.length < 2)
{
return (null);
}
double meanX = java.util.Arrays.stream(x).average().orElseThrow();
double meanY = java.util.Arrays.stream(y).average().orElseThrow();
double xx = 0;
double xy = 0;
for(int i = 0; i < x.length; i++)
{
xx += (x[i] - meanX) * (x[i] - meanX);
xy += (x[i] - meanX) * (y[i] - meanY);
}
return (xx == 0 ? null : new TrendLine().withSlope(xy / xx).withIntercept(meanY - xy / xx * meanX).withR(Statistics.pearson(x, y)));
}
/*******************************************************************************
** Cell counts include zero combinations. Suppression also removes shares
** that could reconstruct a hidden cell from a known marginal total.
*******************************************************************************/
public CrosstabResult crosstab(CrosstabRequest request) throws QException
{
full(request.getRowField());
full(request.getColumnField());
if(request.getRowField().equals(request.getColumnField()) || request.getLimit() == null || request.getLimit() < 1 || request.getLimit() > 20)
{
throw new QBadRequestException("Choose distinct crosstab fields and a limit from 1 to 20.");
}
List<Category> rows = categories(request.getRowField(), request.getFilter(), request.getLimit());
List<Category> columns = categories(request.getColumnField(), request.getFilter(), request.getLimit());
long[] rowTotals = new long[rows.size()];
long[] columnTotals = new long[columns.size()];
boolean[] hiddenRows = new boolean[rows.size()];
boolean[] hiddenColumns = new boolean[columns.size()];
double[][] counts = new double[rows.size()][columns.size()];
boolean hidden = false;
for(int i = 0; i < rows.size(); i++)
{
for(int j = 0; j < columns.size(); j++)
{
long n = size(DrillFilters.and(request.getFilter(), DrillFilters.and(rows.get(i).condition(), columns.get(j).condition())));
counts[i][j] = n;
rowTotals[i] += n;
columnTotals[j] += n;
if(n < config.getMinGroupSize())
{
hidden = true;
hiddenRows[i] = true;
hiddenColumns[j] = true;
}
}
}
CrosstabResult result = new CrosstabResult().withRows(rows.stream().map(c -> new PivotHeader().withValue(c.value()).withLabel(c.label())).toList())
.withColumns(columns.stream().map(c -> new PivotHeader().withValue(c.value()).withLabel(c.label())).toList());
for(int i = 0; i < rows.size(); i++)
{
for(int j = 0; j < columns.size(); j++)
{
long n = (long) counts[i][j];
QQueryFilter condition = DrillFilters.and(rows.get(i).condition(), columns.get(j).condition());
Metric count = metric("count", n, ValueType.COUNT, request.getFilter(), condition);
Metric rowShare = metric("rowShare", ProfileSupport.share(n, rowTotals[i]), ValueType.PERCENT, request.getFilter(), condition);
Metric columnShare = metric("columnShare", ProfileSupport.share(n, columnTotals[j]), ValueType.PERCENT, request.getFilter(), condition);
List<Metric> metrics = List.of(count, rowShare, columnShare);
if(rows.get(i).privateGroup() || columns.get(j).privateGroup())
{
metrics.forEach(m -> m.withDrill(null));
hidden = true;
}
guard(metrics, n);
if(hiddenRows[i])
{
guard(List.of(rowShare), 0);
}
if(hiddenColumns[j])
{
guard(List.of(columnShare), 0);
}
result.getCells().add(new CrosstabCell().withRowIndex(i).withColumnIndex(j).withCount(count).withRowShare(rowShare).withColumnShare(columnShare));
}
}
return (result.withSignificance(significance(hidden ? Statistics.unavailable("Chi-square independence", "privacy guard") : Statistics.chiSquare(counts, false))));
}
}
@@ -0,0 +1,240 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.result.Chart;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.Series;
import com.kingsrook.qbits.dataanalysis.result.Significance;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qbits.dataanalysis.stats.Statistics;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
/*******************************************************************************
** Shared core execution and semantic output for request-local relationships.
*******************************************************************************/
public class RelationshipSupport extends StandInAnalysisBackend
{
protected final AnalysisSecurity security;
protected final String table;
/*******************************************************************************
** A bucket retains exact membership; privacy buckets intentionally lack drills.
*******************************************************************************/
protected record Category(Serializable value, String label, QQueryFilter condition, boolean privateGroup)
{
}
/*******************************************************************************
** Bind the caller's already-authorized table and current policy.
*******************************************************************************/
public RelationshipSupport(AnalysisSecurity security, EffectiveConfig config, String table)
{
super(config);
this.security = security;
this.table = table;
}
/*******************************************************************************
** Core owns pushdown and bounded fallback. Never label a partial test exact.
*******************************************************************************/
@Override
protected List<AggregateResult> aggregate(AggregateInput input) throws QException
{
input.withFilter(DrillFilters.restoreTypes(table, input.getFilter())).withAllowStreamedFallback(true).withStreamedRowLimit(config.getMaxRows());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
var output = com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.aggregate(input);
if(Boolean.TRUE.equals(output.getTruncated()))
{
throw new QBadRequestException("Analysis exceeds the streamed row limit; narrow the filter.");
}
return (output.getResults());
}
/*******************************************************************************
** All named fields, including PVS numerics, must pass the FULL resolver first.
*******************************************************************************/
protected QFieldMetaData full(String name) throws QException
{
return (security.field(table, name));
}
/*******************************************************************************
** PVS identifiers are categorical even when their storage type is numeric.
*******************************************************************************/
protected boolean numeric(QFieldMetaData field)
{
return (field.getPossibleValueSourceName() == null && List.of(QFieldType.INTEGER, QFieldType.LONG, QFieldType.DECIMAL).contains(field.getType()));
}
/*******************************************************************************
** Reject strings and categorical identifiers before computing relationships.
*******************************************************************************/
protected void requireNumeric(String name) throws QException
{
if(!numeric(full(name)))
{
throw new QBadRequestException("Choose numeric analysis fields.");
}
}
/*******************************************************************************
** Typed aggregate keys must match keys returned by all backend implementations.
*******************************************************************************/
protected Aggregate measure(String field, AggregateOperator operator)
{
Aggregate aggregate = new Aggregate(field, operator);
return (aggregate.withFieldType(com.kingsrook.qqq.backend.core.utils.aggregates.AggregateAnalysis.resultType(aggregate, field(table, field).getType())));
}
/*******************************************************************************
** Scalar summaries have one result; empty populations have null numeric values.
*******************************************************************************/
protected AggregateResult summarize(QQueryFilter filter, List<Aggregate> measures) throws QException
{
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter));
measures.forEach(input::withAggregate);
List<AggregateResult> results = aggregate(input);
return (results.isEmpty() ? new AggregateResult() : results.getFirst());
}
/*******************************************************************************
** Exact row count uses the core count action with the caller's record locks.
*******************************************************************************/
protected long size(QQueryFilter filter) throws QException
{
return (count(table, filter));
}
/*******************************************************************************
** Count and scalar drills preserve the caller's original OR predicates.
*******************************************************************************/
protected Metric metric(String key, Serializable value, ValueType type, QQueryFilter filter, QQueryFilter condition)
{
return (ProfileSupport.metric(key.startsWith("category") ? type == ValueType.COUNT ? "count" : "share" : key, value, type).withKey(key).withDrill(DrillFilters.drill(table, filter, condition)));
}
/*******************************************************************************
** Apply the stricter cell guard to remove both values and membership drills.
*******************************************************************************/
protected void guard(List<Metric> metrics, long n)
{
new MinGroupSizeGuard(config.getMinGroupSize()).pivotMetrics(metrics, n);
}
/*******************************************************************************
** Group identities are typed; null and empty string remain distinct.
*******************************************************************************/
protected List<Category> categories(String field, QQueryFilter filter, int limit) throws QException
{
QFieldMetaData metadata = full(field);
GroupBy group = new GroupBy(metadata);
Aggregate count = measure(primaryKey(table), AggregateOperator.COUNT);
List<AggregateResult> groups = new ArrayList<>(aggregate(new AggregateInput(table).withFilter(DrillFilters.copy(filter)).withGroupBy(group).withAggregate(count)));
groups.sort(Comparator.<AggregateResult>comparingLong(r -> ((Number) r.getAggregateValues().get(count)).longValue()).reversed()
.thenComparing(r -> String.valueOf(r.getGroupByValues().get(group))));
List<Category> result = new ArrayList<>();
List<Category> other = new ArrayList<>();
boolean privacy = false;
for(AggregateResult row : groups)
{
Serializable value = row.getGroupByValues().get(group);
long n = ((Number) row.getAggregateValues().get(count)).longValue();
boolean hidden = n < config.getMinGroupSize();
Category category = new Category(value, hidden ? "Other" : value == null ? "(Blank)" : security.label(metadata, value),
DrillFilters.value(field, value, metadata.getType() == QFieldType.STRING), hidden);
if(hidden || result.size() >= limit)
{
other.add(category);
privacy |= hidden;
}
else
{
result.add(category);
}
}
if(!other.isEmpty())
{
result.add(merge(other, privacy));
}
return (result);
}
/*******************************************************************************
** An explicit union avoids null-sensitive NOT IN semantics for Other.
*******************************************************************************/
protected Category merge(List<Category> categories, boolean privacy)
{
QQueryFilter condition = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
categories.forEach(c -> condition.withSubFilter(c.condition()));
return (new Category(null, "Other", condition, privacy));
}
/*******************************************************************************
** Keep the math package free of QQQ and JSON dependencies.
*******************************************************************************/
protected Significance significance(Statistics.Result result)
{
return (new Significance().withTest(result.test()).withStatistic(result.statistic()).withDf(result.df()).withPValue(result.pValue())
.withApplicable(result.applicable()).withSentence(result.sentence()).withCramersV(result.cramersV()));
}
/*******************************************************************************
** Scalar conversion preserves null instead of leaking non-finite JSON numbers.
*******************************************************************************/
protected static BigDecimal decimal(Object value)
{
return (value == null ? null : new BigDecimal(value.toString()));
}
/*******************************************************************************
** Shared chart shape with a deliberate grouping/stacking annotation.
*******************************************************************************/
protected Chart chart(ChartKind kind, List<Series> series, String annotation)
{
return (new Chart().withKind(kind).withSeries(series).withAnnotations(List.of(annotation)));
}
/*******************************************************************************
** Correlation and regression exclude rows missing either numeric coordinate.
*******************************************************************************/
protected QQueryFilter paired(QQueryFilter filter, String x, String y)
{
return (DrillFilters.and(filter, DrillFilters.condition(x, QCriteriaOperator.IS_NOT_BLANK).withCriteria(y, QCriteriaOperator.IS_NOT_BLANK)));
}
}
@@ -0,0 +1,213 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.Point;
import com.kingsrook.qbits.dataanalysis.result.Series;
import com.kingsrook.qbits.dataanalysis.result.ShiftBucket;
import com.kingsrook.qbits.dataanalysis.result.ShiftRequest;
import com.kingsrook.qbits.dataanalysis.result.ShiftResult;
import com.kingsrook.qbits.dataanalysis.result.ValueType;
import com.kingsrook.qbits.dataanalysis.stats.Statistics;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucket;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.DateBucketUnit;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Distribution drift with stable categories or fixed baseline quartile bins.
*******************************************************************************/
public class ShiftExplorer extends ComparisonExplorer
{
/*******************************************************************************
** Bind the same core and privacy policy as compare.
*******************************************************************************/
public ShiftExplorer(AnalysisSecurity security, EffectiveConfig config, String table)
{
super(security, config, table);
}
/*******************************************************************************
** One calendar bucket with exact boundaries and whether its end has passed.
*******************************************************************************/
private record Bucket(Serializable start, QQueryFilter condition, boolean complete)
{
}
/*******************************************************************************
** First and last complete nonempty buckets define the descriptive PSI.
*******************************************************************************/
public ShiftResult shift(ShiftRequest request) throws QException
{
var field = full(request.getField());
var date = full(request.getDateField());
DateBucketUnit unit;
try
{
unit = DateBucketUnit.valueOf(request.getBucket());
}
catch(RuntimeException e)
{
throw new QBadRequestException("Choose a calendar bucket.");
}
if(!List.of(QFieldType.DATE, QFieldType.DATE_TIME).contains(date.getType()) || unit == DateBucketUnit.DAY_OF_WEEK
|| unit == DateBucketUnit.HOUR_OF_DAY || unit == DateBucketUnit.HOUR && date.getType() != QFieldType.DATE_TIME)
{
throw new QBadRequestException("Choose a date field and a calendar bucket from HOUR to YEAR.");
}
GroupBy group = new GroupBy(date).withBucket(new DateBucket(unit));
var aggregate = measure(primaryKey(table), AggregateOperator.COUNT);
var grouped = aggregate(new AggregateInput(table).withFilter(DrillFilters.copy(request.getFilter())).withGroupBy(group).withAggregate(aggregate));
List<Bucket> buckets = grouped.stream().map(r -> r.getGroupByValues().get(group)).filter(java.util.Objects::nonNull)
.sorted(Comparator.comparing(Object::toString)).map(start -> bucket(request.getDateField(), start, unit, date.getType())).toList();
if(buckets.size() > 2000)
{
throw new QBadRequestException("Choose a larger time bucket; at most 2000 buckets are supported.");
}
ShiftResult result = new ShiftResult();
List<Series> series = new ArrayList<>();
List<double[]> complete = new ArrayList<>();
boolean numeric = numeric(field);
List<Category> categories = numeric ? List.of() : categories(request.getField(), request.getFilter(), 8);
Map<String, BigDecimal> baseline = null;
boolean privateData = false;
if(numeric)
{
for(String name : List.of("p25", "median", "p75"))
{
series.add(new Series().withName(name));
}
}
else
{
for(Category category : categories)
{
series.add(new Series().withName(category.label()));
}
}
for(Bucket bucket : buckets)
{
QQueryFilter filter = DrillFilters.and(request.getFilter(), bucket.condition());
long n = size(filter);
ShiftBucket output = new ShiftBucket().withValue(bucket.start()).withLabel(bucket.start().toString()).withComplete(bucket.complete());
double[] counts;
if(numeric)
{
Map<String, BigDecimal> moments = moments(request.getField(), filter);
long nonNull = moments.get("count").longValue();
if(baseline == null && bucket.complete())
{
baseline = moments;
}
output.withMetrics(numericSide(request.getField(), output.getLabel(), filter, moments).getMetrics());
counts = baseline == null || baseline.get("count").signum() == 0 ? new double[0] : numericCounts(request.getField(), filter, baseline);
privateData |= nonNull < config.getMinGroupSize();
for(int i = 0; i < series.size(); i++)
{
String key = series.get(i).getName();
Metric metric = output.getMetrics().stream().filter(m -> m.getKey().equals(key)).findFirst().orElseThrow();
series.get(i).getPoints().add(new Point().withX(bucket.start()).withY(decimal(metric.getValue())).withDrill(metric.getDrill()));
}
}
else
{
counts = new double[categories.size()];
for(int i = 0; i < categories.size(); i++)
{
Category category = categories.get(i);
long count = size(DrillFilters.and(filter, category.condition()));
counts[i] = count;
Metric countMetric = metric("category" + i + "Count", count, ValueType.COUNT, filter, category.condition()).withLabel(category.label());
Metric share = metric("category" + i + "Share", ProfileSupport.share(count, n), ValueType.PERCENT, filter, category.condition()).withLabel(category.label());
List<Metric> metrics = List.of(countMetric, share);
guard(metrics, count);
if(category.privateGroup())
{
metrics.forEach(m -> m.withDrill(null));
}
privateData |= category.privateGroup() || count < config.getMinGroupSize();
output.getMetrics().addAll(metrics);
series.get(i).getPoints().add(new Point().withX(bucket.start()).withY(decimal(share.getValue())).withLabel(category.label()).withDrill(share.getDrill()));
}
}
result.getBuckets().add(output);
if(bucket.complete())
{
complete.add(counts);
}
}
Double psi = !privateData && complete.size() >= 2 && java.util.Arrays.stream(complete.getFirst()).sum() >= 2
&& java.util.Arrays.stream(complete.getLast()).sum() >= 2 ? Statistics.psi(complete.getFirst(), complete.getLast()) : null;
result.withPsi(ProfileSupport.metric("psi", psi, ValueType.DECIMAL));
result.withSentence(psi == null ? "Not applicable: " + (privateData ? "privacy guard" : "two complete buckets with at least two values are required")
: "The first and last complete buckets have a population stability index of " + BigDecimal.valueOf(psi).setScale(3, java.math.RoundingMode.HALF_UP) + ".");
return (result.withChart(chart(ChartKind.TIME_SERIES, series, numeric ? "Numeric quartiles; PSI uses fixed first-complete-bucket quartile boundaries."
: "Stacked category shares. PSI floors zero shares at 0.000001; it is not a significance test.").withStacked(!numeric)));
}
/*******************************************************************************
** Calendar boundaries use the session zone, including daylight-saving days.
*******************************************************************************/
private Bucket bucket(String field, Serializable value, DateBucketUnit unit, QFieldType type)
{
ZoneId zone = ValueUtils.getSessionOrInstanceZoneId();
if(unit == DateBucketUnit.HOUR)
{
Instant start = ValueUtils.getValueAsInstant(value);
Instant end = start.plusSeconds(3600);
return (new Bucket(start, DrillFilters.range(field, start, end, false), !end.isAfter(Instant.now())));
}
LocalDate start = ValueUtils.getValueAsLocalDate(value);
LocalDate end = next(start, DateUnit.valueOf(unit.name()));
return (new Bucket(start, DrillFilters.dateRange(field, start, end, type == QFieldType.DATE_TIME, zone), !end.atStartOfDay(zone).toInstant().isAfter(Instant.now())));
}
/*******************************************************************************
** Both populations use identical, half-open quartile ranges with infinite tails.
*******************************************************************************/
private double[] numericCounts(String field, QQueryFilter filter, Map<String, BigDecimal> baseline) throws QException
{
BigDecimal[] edges = {null, baseline.get("p25"), baseline.get("p50"), baseline.get("p75"), null};
double[] counts = new double[4];
for(int i = 0; i < 4; i++)
{
counts[i] = size(DrillFilters.and(filter, DrillFilters.range(field, edges[i], edges[i + 1], false)));
}
return (counts);
}
}
@@ -0,0 +1,783 @@
/*
* 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.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByAggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByGroupBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
/*******************************************************************************
** Stand-in until QQQ core DATA-ANALYSIS.md §3 operators ship in an RC; then add
** CoreAnalysisBackend that pushes these down. RC.5 estimates use the first
** sample_rows rows by primary key, not random sampling. Streamed operations
** page by primary key and retain at most max_rows records.
*******************************************************************************/
public class StandInAnalysisBackend implements AnalysisBackend
{
private static final MathContext MATH = MathContext.DECIMAL128;
protected final EffectiveConfig config;
protected boolean estimating;
/*******************************************************************************
** Bind one request's detached configuration.
*******************************************************************************/
public StandInAnalysisBackend(EffectiveConfig config)
{
this.config = config;
}
/*******************************************************************************
** CountAction is core's dedicated query-count operation.
*******************************************************************************/
@Override
public long count(String table, QQueryFilter filter) throws QException
{
CountInput input = new CountInput(table).withFilter(DrillFilters.restoreTypes(table, filter));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new CountAction().execute(input).getCount().longValue());
}
/*******************************************************************************
** A single boundary parameter bounds the prefix used for computation.
*******************************************************************************/
@Override
public Scope scope(String table, QQueryFilter filter, boolean exact) throws QException
{
long total = count(table, filter);
boolean estimate = !exact && total > config.getSampleThresholdRows() && total > Math.min(config.getSampleRows(), config.getMaxRows());
estimating = estimate;
int limit = estimate ? Math.min(config.getSampleRows(), config.getMaxRows()) : config.getMaxRows();
QQueryFilter scoped = DrillFilters.copy(filter);
if(total > limit)
{
String pk = primaryKey(table);
if(limit == 0)
{
scoped = DrillFilters.and(scoped, DrillFilters.condition(pk, QCriteriaOperator.IS_BLANK));
}
else
{
List<QRecord> boundary = query(table, scoped, Set.of(pk), limit - 1, 1);
if(boundary.isEmpty())
{
throw new QException("Data changed while selecting analysis rows; retry.");
}
scoped = DrillFilters.and(scoped, DrillFilters.condition(pk, QCriteriaOperator.LESS_THAN_OR_EQUALS, boundary.getFirst().getValue(pk)));
}
}
return (new Scope(scoped, total, Math.min(total, limit), total > limit && (!estimate || limit < config.getSampleRows()), estimate));
}
/*******************************************************************************
** Fetch only fields used by the operation, including requested heavy text.
*******************************************************************************/
private List<QRecord> query(String table, QQueryFilter filter, Set<String> fields, int offset, int limit) throws QException
{
QueryInput input = new QueryInput(table).withFilter(DrillFilters.restoreTypes(table, filter).withOrderBy(new QFilterOrderBy(primaryKey(table))).withSkip(offset).withLimit(limit))
.withFieldNamesToInclude(fields).withShouldFetchHeavyFields(true);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new QueryAction().execute(input).getRecords());
}
/*******************************************************************************
** Bound both page size and retained records.
*******************************************************************************/
protected List<QRecord> rows(String table, QQueryFilter filter, Set<String> fields, int limit) throws QException
{
List<QRecord> records = new ArrayList<>();
int bound = Math.min(limit, config.getMaxRows());
while(records.size() < bound)
{
int size = Math.min(1000, bound - records.size());
List<QRecord> page = query(table, filter, fields, records.size(), size);
records.addAll(page);
if(page.size() < size)
{
break;
}
}
return (records);
}
/*******************************************************************************
** Core applies table permissions, field locks and record locks.
*******************************************************************************/
protected List<AggregateResult> aggregate(AggregateInput input) throws QException
{
QQueryFilter restored = DrillFilters.restoreTypes(input.getTableName(), input.getFilter());
restored.setOrderBys(input.getFilter().getOrderBys());
input.setFilter(restored);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.aggregate(input).getResults());
}
/*******************************************************************************
** Batch supported aggregates; nulls use core criteria. The bounded scan adds
** whitespace blanks and representative records for cardinality drills.
*******************************************************************************/
@Override
public Map<String, FieldCounts> fieldCounts(String table, QQueryFilter filter, List<String> fields) throws QException
{
Map<String, FieldCounts> result = new LinkedHashMap<>();
long total = count(table, filter);
for(int start = 0; start < fields.size(); start += 20)
{
List<String> batch = fields.subList(start, Math.min(start + 20, fields.size()));
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter));
for(String field : batch)
{
input.withAggregate(countOf(field, AggregateOperator.COUNT)).withAggregate(countOf(field, AggregateOperator.COUNT_DISTINCT));
}
fieldCountAggregates(input, batch);
List<AggregateResult> aggregates = aggregate(input);
Set<String> selection = new LinkedHashSet<>(batch);
selection.add(primaryKey(table));
List<QRecord> records = rows(table, filter, selection, config.getMaxRows());
for(String field : batch)
{
boolean string = field(table, field).getType().isStringLike();
long nulls = fieldCount(table, filter, field, AggregateOperator.COUNT_NULL, aggregates, DrillFilters.nulls(field, string));
Map<Serializable, Serializable> representatives = new LinkedHashMap<>();
Set<Serializable> blanks = new LinkedHashSet<>();
for(QRecord record : records)
{
Serializable value = record.getValue(field);
if(value != null)
{
representatives.putIfAbsent(value instanceof BigDecimal d ? d.stripTrailingZeros() : value, record.getValue(primaryKey(table)));
if(string && value.toString().isBlank())
{
blanks.add(value);
}
}
}
QQueryFilter blankFilter = DrillFilters.nulls(field, string);
if(!blanks.isEmpty())
{
blankFilter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(blankFilter)
.withSubFilter(DrillFilters.in(field, new ArrayList<>(blanks)));
}
long distinct = aggregates.isEmpty() ? 0 : number(aggregates.getFirst(), field, AggregateOperator.COUNT_DISTINCT).longValue();
if(!estimating && isMemory(table) && field(table, field).getType() == QFieldType.DECIMAL)
{
distinct = representatives.size();
}
result.put(field, new FieldCounts(total - nulls, nulls, string ? fieldCount(table, filter, field, AggregateOperator.COUNT_BLANK, aggregates, blankFilter) : nulls,
distinct, blankFilter, estimating ? DrillFilters.nonNull(field, string) : DrillFilters.in(primaryKey(table), new ArrayList<>(representatives.values()))));
}
}
return (result);
}
/*******************************************************************************
** Subclasses may extend the existing batch with native null/blank counts.
*******************************************************************************/
protected void fieldCountAggregates(AggregateInput input, List<String> fields) throws QException
{
// The portable backend counts null and blank predicates separately.
}
/*******************************************************************************
** Count the exact drill predicate when native counters are unavailable.
*******************************************************************************/
protected long fieldCount(String table, QQueryFilter filter, String field, AggregateOperator operator,
List<AggregateResult> results, QQueryFilter condition) throws QException
{
return (count(table, DrillFilters.and(filter, condition)));
}
/*******************************************************************************
** Push down group, count, ordering and pagination.
*******************************************************************************/
@Override
public List<GroupCount> topValues(String table, QQueryFilter filter, String field, int limit, int offset, String search) throws QException
{
QQueryFilter selected = DrillFilters.copy(filter);
if(search != null && !search.isBlank())
{
if(!field(table, field).getType().isStringLike())
{
return (groups(table, selected, List.of(field), Math.max(1, config.getMaxRows()), 0, false).stream()
.filter(g -> g.value() != null && g.value().toString().contains(search)).skip(offset).limit(limit).toList());
}
selected = DrillFilters.and(selected, DrillFilters.condition(field, QCriteriaOperator.CONTAINS, search));
}
return (groups(table, selected, List.of(field), limit, offset, false));
}
/*******************************************************************************
** Random backends scale a count using the current aggregate's sample size.
*******************************************************************************/
protected long aggregateCount(long value)
{
return (value);
}
/*******************************************************************************
** Random backends retain the raw sample ratio before rounding counts.
*******************************************************************************/
protected BigDecimal aggregateShare(long value)
{
return (null);
}
/*******************************************************************************
** Exact and prefix groups derive shares from the profiler's row count.
*******************************************************************************/
protected BigDecimal countShare()
{
return (null);
}
/*******************************************************************************
** Tuple drills use IN for non-null values and null-only predicates otherwise.
*******************************************************************************/
private List<GroupCount> groups(String table, QQueryFilter filter, List<String> fields, int limit, int offset, boolean duplicates) throws QException
{
Aggregate count = countOf(primaryKey(table), AggregateOperator.COUNT);
QQueryFilter ordered = DrillFilters.copy(filter).withOrderBy(new QFilterOrderByAggregate(count, false));
AggregateInput input = new AggregateInput(table).withFilter(ordered).withAggregate(count).withLimit(Math.addExact(limit, offset));
for(String field : fields)
{
input.withGroupBy(new GroupBy(field(table, field)));
ordered.withOrderBy(new QFilterOrderByGroupBy(new GroupBy(field(table, field))));
}
Map<Serializable, GroupCount> merged = new LinkedHashMap<>();
for(AggregateResult row : aggregate(input))
{
long rawSize = number(row, primaryKey(table), AggregateOperator.COUNT).longValue();
long size = aggregateCount(rawSize);
LinkedHashMap<String, Serializable> tuple = new LinkedHashMap<>();
QQueryFilter condition = new QQueryFilter();
for(String field : fields)
{
Serializable value = row.getGroupByValues().entrySet().stream().filter(e -> field.equals(e.getKey().getFieldName())).findFirst().orElseThrow().getValue();
tuple.put(field, value instanceof BigDecimal d ? new BigDecimal(d.stripTrailingZeros().toPlainString()) : value);
condition.withSubFilter(value == null ? DrillFilters.nulls(field, field(table, field).getType().isStringLike())
: value instanceof BigDecimal && !estimating ? DrillFilters.in(field, List.of(value.toString()))
: duplicates ? DrillFilters.in(field, List.of(value)) : DrillFilters.value(field, value, field(table, field).getType().isStringLike()));
}
Serializable value = duplicates ? tuple : tuple.get(fields.getFirst());
GroupCount previous = merged.get(value);
if(previous != null)
{
size += previous.count();
condition = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(previous.condition()).withSubFilter(condition);
}
merged.put(value, new GroupCount(value, value == null ? "(null)" : value.toString(), size, condition, aggregateShare(rawSize)));
}
List<GroupCount> groups = new ArrayList<>(merged.values());
if(isMemory(table))
{
groups.sort(Comparator.comparingLong(GroupCount::count).reversed().thenComparing(GroupCount::label));
}
return (groups.stream().filter(g -> !duplicates || g.count() > 1).skip(offset).limit(limit).toList());
}
/*******************************************************************************
** Push down basic moments; stream ordered values for variance and percentiles.
*******************************************************************************/
@Override
public NumericSummary numericSummary(String table, QQueryFilter filter, String field, List<BigDecimal> percentiles) throws QException
{
List<BigDecimal> values = rows(table, filter, Set.of(field), config.getMaxRows()).stream().map(r -> r.getValue(field))
.filter(Objects::nonNull).map(v -> new BigDecimal(v.toString())).sorted().toList();
Map<String, BigDecimal> metrics = numericMetrics(table, filter, field, percentiles, values);
if(metrics.isEmpty() || metrics.get("min") == null)
{
for(String key : List.of("min", "max", "mean", "stddev", "sum"))
{
metrics.put(key, null);
}
for(BigDecimal p : percentiles)
{
metrics.put("p" + p.multiply(BigDecimal.valueOf(100)).stripTrailingZeros().toPlainString(), null);
}
QQueryFilter empty = DrillFilters.condition(primaryKey(table), QCriteriaOperator.IS_BLANK);
return (new NumericSummary(metrics, List.of(), 0, empty, List.of(new GroupCount("zeros", "zeros", 0, estimating ? DrillFilters.condition(field, QCriteriaOperator.EQUALS, BigDecimal.ZERO) : empty),
new GroupCount("negatives", "negatives", 0, estimating ? DrillFilters.range(field, null, BigDecimal.ZERO, false) : empty))));
}
BigDecimal q1 = metrics.containsKey("p25") ? metrics.get("p25") : percentile(values, new BigDecimal("0.25"));
BigDecimal q3 = metrics.containsKey("p75") ? metrics.get("p75") : percentile(values, new BigDecimal("0.75"));
BigDecimal span = q3.subtract(q1).multiply(new BigDecimal("1.5"));
BigDecimal low = q1.subtract(span);
BigDecimal high = q3.add(span);
QQueryFilter outliers = isMemory(table)
? new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withSubFilter(numericRange(table, field, values, null, low, false))
.withSubFilter(DrillFilters.and(DrillFilters.condition(field, QCriteriaOperator.IS_NOT_BLANK), DrillFilters.condition(field, QCriteriaOperator.GREATER_THAN, high)))
: DrillFilters.outliers(field, low, high);
List<BigDecimal> extremes = values.stream().filter(v -> v.compareTo(low) < 0 || v.compareTo(high) > 0).map(v -> new BigDecimal(v.stripTrailingZeros().toPlainString())).distinct()
.sorted(Comparator.comparing((BigDecimal v) -> v.subtract(q1.add(q3).divide(BigDecimal.valueOf(2), MATH)).abs()).reversed()).limit(10).toList();
List<GroupCount> extremeGroups = new ArrayList<>();
for(BigDecimal value : extremes)
{
QQueryFilter condition = estimating ? DrillFilters.condition(field, QCriteriaOperator.EQUALS, value) : DrillFilters.in(field, equalNumbers(values, value));
extremeGroups.add(new GroupCount(value, value.toPlainString(), count(table, DrillFilters.and(filter, condition)), condition, countShare()));
}
QQueryFilter zeros = estimating ? DrillFilters.condition(field, QCriteriaOperator.EQUALS, BigDecimal.ZERO) : DrillFilters.in(field, equalNumbers(values, BigDecimal.ZERO));
QQueryFilter negatives = numericRange(table, field, values, null, BigDecimal.ZERO, false);
return (new NumericSummary(metrics, extremeGroups, count(table, DrillFilters.and(filter, outliers)), outliers,
List.of(bucket(table, filter, "zeros", zeros), bucket(table, filter, "negatives", negatives))));
}
/*******************************************************************************
** Portable numeric moments; core-backed subclasses replace supported work.
*******************************************************************************/
protected Map<String, BigDecimal> numericMetrics(String table, QQueryFilter filter, String field, List<BigDecimal> percentiles, List<BigDecimal> values) throws QException
{
Map<String, BigDecimal> metrics = new LinkedHashMap<>();
if(values.isEmpty())
{
return (metrics);
}
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter));
for(AggregateOperator op : List.of(AggregateOperator.MIN, AggregateOperator.MAX, AggregateOperator.AVG, AggregateOperator.SUM))
{
input.withAggregate(new Aggregate(field, op));
}
List<AggregateResult> results = aggregate(input);
AggregateResult result = results.getFirst();
metrics.put("min", number(result, field, AggregateOperator.MIN));
metrics.put("max", number(result, field, AggregateOperator.MAX));
metrics.put("mean", number(result, field, AggregateOperator.AVG));
metrics.put("sum", number(result, field, AggregateOperator.SUM));
BigDecimal mean = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(values.size()), MATH);
BigDecimal variance = values.stream().map(v -> v.subtract(mean).pow(2)).reduce(BigDecimal.ZERO, BigDecimal::add);
metrics.put("stddev", values.size() < 2 ? null : variance.divide(BigDecimal.valueOf(values.size() - 1), MATH).sqrt(MATH));
for(BigDecimal p : percentiles)
{
metrics.put("p" + p.multiply(BigDecimal.valueOf(100)).stripTrailingZeros().toPlainString(), percentile(values, p));
}
return (metrics);
}
/*******************************************************************************
** PostgreSQL percentile_cont interpolation at p * (n - 1).
*******************************************************************************/
public static BigDecimal percentile(List<BigDecimal> sorted, BigDecimal p)
{
if(sorted.isEmpty())
{
return (null);
}
BigDecimal index = p.multiply(BigDecimal.valueOf(sorted.size() - 1));
int low = index.intValue();
int high = Math.min(low + 1, sorted.size() - 1);
return (sorted.get(low).add(sorted.get(high).subtract(sorted.get(low)).multiply(index.subtract(BigDecimal.valueOf(low)))));
}
/*******************************************************************************
** Predicates share the exact boundary arithmetic used by drills.
*******************************************************************************/
@Override
public List<GroupCount> numericHistogram(String table, QQueryFilter filter, String field, BigDecimal low, BigDecimal high, int bins) throws QException
{
List<GroupCount> result = new ArrayList<>();
if(low == null || high == null)
{
return (result);
}
NiceBins nice = NiceBins.covering(low, high);
low = nice.low();
high = nice.high();
bins = nice.count();
List<BigDecimal> values = rows(table, filter, Set.of(field), config.getMaxRows()).stream().map(r -> r.getValue(field))
.filter(Objects::nonNull).map(v -> new BigDecimal(v.toString())).toList();
result.add(bucket(table, filter, AnalysisFormatting.interval(field(table, field), null, low), numericRange(table, field, values, null, low, false)));
BigDecimal width = high.subtract(low).divide(BigDecimal.valueOf(bins), MATH);
for(int i = 0; i < bins; i++)
{
BigDecimal start = low.add(width.multiply(BigDecimal.valueOf(i)));
BigDecimal end = i == bins - 1 ? high : low.add(width.multiply(BigDecimal.valueOf(i + 1)));
result.add(bucket(table, filter, AnalysisFormatting.interval(field(table, field), start, end), numericRange(table, field, values, start, end, i == bins - 1)));
}
result.add(bucket(table, filter, AnalysisFormatting.interval(field(table, field), high, null), DrillFilters.and(DrillFilters.condition(field, QCriteriaOperator.IS_NOT_BLANK), DrillFilters.condition(field, QCriteriaOperator.GREATER_THAN, high))));
return (result);
}
/*******************************************************************************
** A bucket count and its producing condition.
*******************************************************************************/
private GroupCount bucket(String table, QQueryFilter filter, String label, QQueryFilter condition) throws QException
{
return (new GroupCount(label, label, count(table, DrillFilters.and(filter, condition)), condition, countShare()));
}
/*******************************************************************************
** Fill gaps inside the observed range, retaining DST-aware boundaries.
*******************************************************************************/
@Override
public List<GroupCount> dateBuckets(String table, QQueryFilter filter, String field, DateUnit unit, ZoneId zone) throws QException
{
List<QRecord> records = rows(table, filter, Set.of(field), config.getMaxRows());
if(unit == DateUnit.DAY_OF_WEEK || unit == DateUnit.HOUR_OF_DAY)
{
return (mappedGroups(table, filter, field, records, v -> unit == DateUnit.DAY_OF_WEEK ? day(v, zone).getDayOfWeek().getValue()
: ((Instant) v).atZone(zone).getHour(), 24).stream()
.map(g -> new GroupCount(g.value(), AnalysisFormatting.dateBucket(g.value(), unit, zone), g.count(), g.condition(), g.share())).toList());
}
if(unit == DateUnit.HOUR)
{
List<Instant> instants = records.stream().map(r -> (Instant) r.getValue(field)).filter(Objects::nonNull).sorted().toList();
List<GroupCount> hours = new ArrayList<>();
if(!instants.isEmpty())
{
Instant start = instants.getFirst().atZone(zone).truncatedTo(java.time.temporal.ChronoUnit.HOURS).toInstant();
while(!start.isAfter(instants.getLast()))
{
Instant end = start.plus(1, java.time.temporal.ChronoUnit.HOURS);
QQueryFilter condition = DrillFilters.and(DrillFilters.condition(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, start),
DrillFilters.condition(field, QCriteriaOperator.LESS_THAN, end));
hours.add(new GroupCount(start, AnalysisFormatting.dateBucket(start, unit, zone), count(table, DrillFilters.and(filter, condition)), condition, countShare()));
start = end;
}
}
return (hours);
}
List<LocalDate> dates = records.stream().map(r -> r.getValue(field)).filter(Objects::nonNull).map(v -> day(v, zone)).sorted().toList();
List<GroupCount> result = new ArrayList<>();
if(dates.isEmpty())
{
return (result);
}
LocalDate start = floor(dates.getFirst(), unit);
LocalDate last = floor(dates.getLast(), unit);
while(!start.isAfter(last))
{
LocalDate end = next(start, unit);
QQueryFilter condition = DrillFilters.dateRange(field, start, end, field(table, field).getType() == QFieldType.DATE_TIME, zone);
result.add(new GroupCount(start, AnalysisFormatting.dateBucket(start, unit, zone), count(table, DrillFilters.and(filter, condition)), condition, countShare()));
start = end;
}
return (result);
}
/*******************************************************************************
** Dates carry no time-zone conversion; instants do.
*******************************************************************************/
public static LocalDate day(Serializable value, ZoneId zone)
{
return (ProfileSupport.day(value, zone));
}
/*******************************************************************************
** ISO weeks begin Monday.
*******************************************************************************/
protected static LocalDate floor(LocalDate date, DateUnit unit)
{
return (switch(unit)
{
case WEEK -> date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
case MONTH -> date.withDayOfMonth(1);
case QUARTER -> date.withMonth((date.getMonthValue() - 1) / 3 * 3 + 1).withDayOfMonth(1);
case YEAR -> date.withDayOfYear(1);
default -> date;
});
}
/*******************************************************************************
** Calendar arithmetic handles leap years and daylight savings.
*******************************************************************************/
protected static LocalDate next(LocalDate date, DateUnit unit)
{
return (switch(unit)
{
case WEEK -> date.plusWeeks(1);
case MONTH -> date.plusMonths(1);
case QUARTER -> date.plusMonths(3);
case YEAR -> date.plusYears(1);
default -> date.plusDays(1);
});
}
/*******************************************************************************
** Preserve types in minimum and maximum aggregate values.
*******************************************************************************/
@Override
public Map<String, Serializable> extrema(String table, QQueryFilter filter, String field) throws QException
{
AggregateInput input = new AggregateInput(table).withFilter(DrillFilters.copy(filter))
.withAggregate(new Aggregate(field, AggregateOperator.MIN)).withAggregate(new Aggregate(field, AggregateOperator.MAX));
Map<String, Serializable> result = new LinkedHashMap<>();
result.put("min", null);
result.put("max", null);
for(AggregateResult row : aggregate(input))
{
row.getAggregateValues().forEach((key, value) ->
{
if(key.getOperator() == AggregateOperator.MIN || key.getOperator() == AggregateOperator.MAX)
{
result.put(key.getOperator().name().toLowerCase(java.util.Locale.ROOT), value);
}
});
}
return (result);
}
/*******************************************************************************
** Stream string lengths; bucket members retain exact value-based drills.
*******************************************************************************/
@Override
public LengthSummary lengthSummary(String table, QQueryFilter filter, String field) throws QException
{
List<QRecord> records = rows(table, filter, Set.of(field), config.getMaxRows());
List<Integer> lengths = records.stream().map(r -> r.getValueString(field)).filter(Objects::nonNull).map(String::length).sorted().toList();
Map<String, BigDecimal> metrics = new LinkedHashMap<>();
if(!lengths.isEmpty())
{
metrics.put("minLength", BigDecimal.valueOf(lengths.getFirst()));
metrics.put("maxLength", BigDecimal.valueOf(lengths.getLast()));
metrics.put("meanLength", BigDecimal.valueOf(lengths.stream().mapToLong(Integer::longValue).sum()).divide(BigDecimal.valueOf(lengths.size()), MATH));
}
int width = lengths.isEmpty() ? 1 : Math.max(1, (int) Math.ceil((lengths.getLast() + 1.0) / 20));
return (new LengthSummary(metrics, mappedGroups(table, filter, field, records, v -> (v.toString().length() / width * width) + "–" + (v.toString().length() / width * width + width - 1), 20)));
}
/*******************************************************************************
** Shapes use a primary-key prefix of at most the requested sample size.
*******************************************************************************/
@Override
public PatternSummary patterns(String table, QQueryFilter filter, String field, int sampleRows) throws QException
{
List<QRecord> records = rows(table, DrillFilters.and(filter, DrillFilters.nonNull(field, true)), Set.of(field, primaryKey(table)), sampleRows + 1);
boolean truncated = records.size() > sampleRows;
records = records.subList(0, Math.min(records.size(), sampleRows));
QQueryFilter sampled = records.isEmpty() ? DrillFilters.in(primaryKey(table), List.of())
: DrillFilters.condition(primaryKey(table), QCriteriaOperator.LESS_THAN_OR_EQUALS, records.getLast().getValue(primaryKey(table)));
return (new PatternSummary(mappedGroups(table, estimating ? filter : DrillFilters.and(filter, sampled), field, records, v -> shape(v.toString()), 10), records.size(), truncated));
}
/*******************************************************************************
** Collapse runs of letters and digits, preserving literal punctuation.
*******************************************************************************/
public static String shape(String text)
{
return (text.replaceAll("\\p{L}+", "A").replaceAll("\\p{N}+", "9"));
}
/*******************************************************************************
** Derived groups do not require unsupported SQL functions.
*******************************************************************************/
private List<GroupCount> mappedGroups(String table, QQueryFilter filter, String field, List<QRecord> rows, Function<Serializable, Serializable> mapper, int limit) throws QException
{
Map<Serializable, Set<Serializable>> members = new LinkedHashMap<>();
for(QRecord row : rows)
{
Serializable value = row.getValue(field);
if(value != null)
{
members.computeIfAbsent(mapper.apply(value), ignored -> new LinkedHashSet<>()).add(value);
}
}
List<GroupCount> groups = new ArrayList<>();
for(Map.Entry<Serializable, Set<Serializable>> entry : members.entrySet())
{
QQueryFilter condition = DrillFilters.in(field, new ArrayList<>(entry.getValue()));
groups.add(new GroupCount(entry.getKey(), entry.getKey().toString(), count(table, DrillFilters.and(filter, condition)), estimating ? condition : DrillFilters.and(filter, condition), countShare()));
}
return (groups.stream().sorted(Comparator.comparingLong(GroupCount::count).reversed().thenComparing(GroupCount::label)).limit(limit).toList());
}
/*******************************************************************************
** Top duplicate groups are sorted before the count threshold is applied.
*******************************************************************************/
@Override
public List<GroupCount> duplicates(String table, QQueryFilter filter, List<String> fields, int limit) throws QException
{
return (groups(table, filter, fields, limit, 0, true));
}
/*******************************************************************************
** Metadata lookup follows facade security checks.
*******************************************************************************/
protected static QFieldMetaData field(String table, String field)
{
return (QContext.getQInstance().getTable(table).getField(field));
}
/*******************************************************************************
** Deterministic paging requires an addressable primary key.
*******************************************************************************/
protected static String primaryKey(String table)
{
return (QContext.getQInstance().getTable(table).getPrimaryKeyField());
}
/*******************************************************************************
** COUNT and COUNT_DISTINCT always read as integers: RC.5's RDBMS reader
** otherwise reads them as the field's type (a boolean count fails).
*******************************************************************************/
protected static Aggregate countOf(String field, AggregateOperator op)
{
return (new Aggregate(field, op).withFieldType(QFieldType.INTEGER));
}
/*******************************************************************************
** The value of one aggregate of a result row (0 when absent).
*******************************************************************************/
protected static BigDecimal number(AggregateResult row, String field, AggregateOperator op)
{
Serializable value = row.getAggregateValues().entrySet().stream().filter(e -> field.equals(e.getKey().getFieldName()) && op == e.getKey().getOperator())
.map(Map.Entry::getValue).filter(Objects::nonNull).findFirst().orElse(0);
return (new BigDecimal(value.toString()));
}
/*******************************************************************************
** RC.5 memory equality is scale-sensitive (BigDecimal.equals), unlike SQL.
** Use strict comparison plus actual boundary spellings to preserve half-open
** membership. SQL backends use ordinary range predicates.
*******************************************************************************/
protected QQueryFilter numericRange(String table, String field, List<BigDecimal> values, BigDecimal low, BigDecimal high, boolean closed)
{
if(estimating || !isMemory(table))
{
return (DrillFilters.range(field, low, high, closed));
}
QQueryFilter result = DrillFilters.condition(field, QCriteriaOperator.IS_NOT_BLANK);
if(low != null)
{
result.withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withSubFilter(DrillFilters.condition(field, QCriteriaOperator.GREATER_THAN, low))
.withSubFilter(DrillFilters.in(field, equalNumbers(values, low))));
}
if(high != null)
{
result.withCriteria(field, QCriteriaOperator.LESS_THAN_OR_EQUALS, high);
List<String> boundary = equalNumbers(values, high);
if(!closed && !boundary.isEmpty())
{
result.withCriteria(field, QCriteriaOperator.NOT_IN, boundary);
}
}
return (result);
}
/*******************************************************************************
** Preserve scale through JSON using string operands, accepted by core and SQL.
*******************************************************************************/
private static List<String> equalNumbers(List<BigDecimal> values, BigDecimal target)
{
return (values.stream().filter(v -> v.compareTo(target) == 0).map(BigDecimal::toString).distinct().toList());
}
/*******************************************************************************
** Isolate compatibility fixes to the RC.5 memory implementation.
*******************************************************************************/
protected static boolean isMemory(String table)
{
return ("memory".equals(QContext.getQInstance().getBackend(QContext.getQInstance().getTable(table).getBackendName()).getBackendType()));
}
}
@@ -0,0 +1,122 @@
/*
* 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.engine;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.result.CandidateKey;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.FieldSummary;
import com.kingsrook.qbits.dataanalysis.result.Point;
import com.kingsrook.qbits.dataanalysis.result.TableProfile;
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
/*******************************************************************************
** Batched table completeness and candidate-key discovery over the selected scope.
*******************************************************************************/
public class TableProfiler
{
private final AnalysisBackend backend;
/*******************************************************************************
** Bind the primitive operation port.
*******************************************************************************/
public TableProfiler(AnalysisBackend backend)
{
this.backend = backend;
}
/*******************************************************************************
** Candidate pairs consider the eight most unique readable selected fields.
*******************************************************************************/
public TableProfile profile(TableProfileRequest request, List<QFieldMetaData> fields, AnalysisBackend.Scope scope) throws QException
{
String table = request.getTableName();
QQueryFilter filter = scope.filter();
long rows = backend.count(table, filter);
QQueryFilter drillFilter = scope.estimate() ? request.getFilter() : filter;
TableProfile result = new TableProfile().withTableName(table).withFilter(DrillFilters.restoreTypes(request.getTableName(), request.getFilter())).withEstimate(scope.estimate())
.withSampleRows(scope.estimate() ? scope.rowsScanned() : 0L).withRowsScanned(scope.rowsScanned()).withTruncated(scope.truncated()).withAsOf(Instant.now())
.withRowCount(ProfileSupport.count("rows", scope.estimate() ? scope.total() : rows, table, drillFilter, null));
Map<String, AnalysisBackend.FieldCounts> counts = backend.fieldCounts(table, filter, fields.stream().map(QFieldMetaData::getName).toList());
for(QFieldMetaData field : fields)
{
String name = field.getName();
AnalysisBackend.FieldCounts c = counts.get(name);
result.getFields().add(new FieldSummary().withField(name).withLabel(field.getLabel()).withType(field.getType().name())
.withCompleteness(AnalysisFormatting.ratio(c.completeness())).withDistinct(c.distinct()).withUniqueness(AnalysisFormatting.ratio(c.uniqueness()))
.withConstant(c.constant()).withEmpty(c.blanks() == rows)
.withNulls(ProfileSupport.count("nulls", c.nulls(), table, drillFilter, DrillFilters.nulls(name, field.getType().isStringLike())))
.withBlanks(ProfileSupport.count("blanks", c.blanks(), table, drillFilter, c.blankFilter())));
if(rows > 0 && c.distinct() == rows && c.nulls() == 0)
{
result.getCandidateKeys().add(key(table, drillFilter, List.of(name), rows));
}
}
List<FieldSummary> ranked = result.getFields().stream().sorted(Comparator.comparing(FieldSummary::getUniqueness).reversed().thenComparing(FieldSummary::getField)).limit(8).toList();
for(int i = 0; i < ranked.size(); i++)
{
for(int j = i + 1; j < ranked.size(); j++)
{
List<String> pair = List.of(ranked.get(i).getField(), ranked.get(j).getField());
if(rows > 0 && pair.stream().allMatch(f -> counts.get(f).nulls() == 0) && backend.duplicates(table, filter, pair, 1).isEmpty())
{
result.getCandidateKeys().add(key(table, drillFilter, pair, rows));
}
}
}
result.withHeatmap(ProfileSupport.chart(ChartKind.HEATMAP, ChartKind.BAR, "field", List.of()));
result.getHeatmap().getY().withLabel("Completeness").withValueType(com.kingsrook.qbits.dataanalysis.result.ValueType.PERCENT);
result.getHeatmap().getSeries().getFirst().withName("Completeness").withPoints(new ArrayList<>());
for(FieldSummary field : result.getFields())
{
result.getHeatmap().getSeries().getFirst().getPoints().add(new Point().withX(field.getField()).withLabel(field.getLabel()).withY(field.getCompleteness()));
}
if(scope.estimate())
{
result.getFields().forEach(f -> ProfileSupport.markEstimates(List.of(f.getNulls(), f.getBlanks())));
result.getWarnings().add(scope.warning());
}
else if(scope.truncated())
{
result.getWarnings().add("Profiles and candidate keys describe only the first " + rows + " rows by primary key.");
}
return (result);
}
/*******************************************************************************
** Candidate-key counts drill to the whole assessed population.
*******************************************************************************/
private static CandidateKey key(String table, QQueryFilter filter, List<String> fields, long rows)
{
return (new CandidateKey().withFields(fields).withRowCount(rows).withDrill(DrillFilters.drill(table, filter, null)));
}
}
@@ -0,0 +1,75 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.metadata;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitProducer;
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
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.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The qbit's component producer, discovered only under the metadata package.
*******************************************************************************/
public class DataAnalysisMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, DataAnalysisQBitConfig>
{
/*******************************************************************************
** Skip standalone host scans until configured by the owning qbit.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null);
}
/*******************************************************************************
** Produce visible tables, sources, locks, relations and the Analyses home.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance)
{
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
DataAnalysisTables.possibleValueSources().forEach(output::add);
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)
{
table.setSourceQBitName(instance.getQBits().values().stream()
.filter(qbit -> DataAnalysisQBitProducer.ARTIFACT_ID.equals(qbit.getArtifactId())).findFirst().orElseThrow().getName());
output.add(table);
}
DataAnalysisHome.widgets().forEach(output::add);
output.add(com.kingsrook.qbits.dataanalysis.app.SavedAnalysisWidget.widget());
output.add(DataAnalysisHome.app(getQBitConfig(), tables));
return (output);
}
}
@@ -0,0 +1,93 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.metadata;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.ScheduleActions;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Watch record actions and one hidden scheduled process, never app children.
*******************************************************************************/
public class ScheduleProcesses extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, DataAnalysisQBitConfig>
{
/*******************************************************************************
** Build after the watch table has been registered.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (200);
}
/*******************************************************************************
** Component scanning also encounters unconfigured producers.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null);
}
/*******************************************************************************
** Ordinary core actions provide selected records, Review, and Result screens.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance)
{
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
for(String name : List.of(ScheduleActions.SEND))
{
String label = "Send now";
QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(name).withLabel(label).withIcon(new QIcon("notifications_active"))
.withTableName(DataAnalysisTables.SCHEDULE).withSourceTable(DataAnalysisTables.SCHEDULE).withDestinationTable(DataAnalysisTables.SCHEDULE)
.withExtractStepClass(ExtractViaQueryStep.class).withTransformStepClass(ScheduleActions.Review.class).withLoadStepClass(ScheduleActions.Result.class)
.withSupportsFullValidation(true).withDoFullValidation(true).withMinInputRecords(1)
.withPreviewMessage("Review the selected schedules.").getProcessMetaData();
// Table READ/EDIT and owner locks are enforced by extraction and both steps.
process.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED));
output.add(process);
}
QProcessMetaData scheduled = new QProcessMetaData().withName(ScheduleActions.SCHEDULED).withLabel("Run analysis schedules").withIsHidden(true)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(ScheduleActions.SCHEDULED))
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(ScheduleActions.Scheduled.class)));
if(getQBitConfig().getSchedulerName() != null)
{
scheduled.withSchedule(new QScheduleMetaData().withSchedulerName(getQBitConfig().getSchedulerName()).withRepeatSeconds(300).withInitialDelaySeconds(300));
}
output.add(scheduled);
return (output);
}
}
@@ -0,0 +1,93 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.metadata;
import java.util.List;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qbits.dataanalysis.processes.WatchActions;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Watch record actions and one hidden scheduled process, never app children.
*******************************************************************************/
public class WatchProcesses extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, DataAnalysisQBitConfig>
{
/*******************************************************************************
** Build after the watch table has been registered.
*******************************************************************************/
@Override
public int getSortOrder()
{
return (200);
}
/*******************************************************************************
** Component scanning also encounters unconfigured producers.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null);
}
/*******************************************************************************
** Ordinary core actions provide selected records, Review, and Result screens.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance)
{
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
for(String name : List.of(WatchActions.EVALUATE, WatchActions.ENABLE, WatchActions.DISABLE))
{
String label = WatchActions.EVALUATE.equals(name) ? "Evaluate now" : WatchActions.ENABLE.equals(name) ? "Enable" : "Disable";
QProcessMetaData process = StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(name).withLabel(label).withIcon(new QIcon("notifications_active"))
.withTableName(DataAnalysisTables.WATCH).withSourceTable(DataAnalysisTables.WATCH).withDestinationTable(DataAnalysisTables.WATCH)
.withExtractStepClass(ExtractViaQueryStep.class).withTransformStepClass(WatchActions.Review.class).withLoadStepClass(WatchActions.Result.class)
.withSupportsFullValidation(true).withDoFullValidation(true).withMinInputRecords(1)
.withPreviewMessage("Review the selected watches.").getProcessMetaData();
// Table READ/EDIT and owner locks are enforced by extraction and both steps.
process.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED));
output.add(process);
}
QProcessMetaData scheduled = new QProcessMetaData().withName(WatchActions.SCHEDULED).withLabel("Evaluate watches").withIsHidden(true)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName(WatchActions.SCHEDULED))
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(WatchActions.Scheduled.class)));
if(getQBitConfig().getSchedulerName() != null)
{
scheduled.withSchedule(new QScheduleMetaData().withSchedulerName(getQBitConfig().getSchedulerName()).withRepeatSeconds(900).withInitialDelaySeconds(900));
}
output.add(scheduled);
return (output);
}
}
@@ -0,0 +1,112 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.model;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Fields of the table selected in the settings form. Core has no multi-select
** PVS field yet; settings retain their comma-separated TEXT representation.
*******************************************************************************/
public class DataAnalysisFieldPossibleValueProvider implements QCustomPossibleValueProvider<String>
{
public static final String NAME = "data_analysis_fields";
/*******************************************************************************
** Without form values the table is unknown; preserve names, including CSV lists.
*******************************************************************************/
@Override
public QPossibleValue<String> getPossibleValue(Serializable idValue)
{
String name = ValueUtils.getValueAsString(idValue);
return (name == null ? null : new QPossibleValue<>(name, name));
}
/*******************************************************************************
** Offer only fields of the selected table, honoring search text and id lookups.
*******************************************************************************/
@Override
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input)
{
List<QPossibleValue<String>> values = new ArrayList<>();
QTableMetaData table = resolveTable(input.getOtherValues());
if(table == null)
{
return (values);
}
for(QFieldMetaData field : table.getFields().values())
{
String label = field.getLabel() == null ? field.getName() : field.getLabel();
QPossibleValue<String> value = new QPossibleValue<>(field.getName(), label + " (" + field.getName() + ")");
if(CollectionUtils.nullSafeHasContents(input.getIdList()))
{
if(input.getIdList().stream().anyMatch(id -> value.getId().equals(ValueUtils.getValueAsString(id))))
{
values.add(value);
}
}
else
{
String term = input.getSearchTerm();
if(!StringUtils.hasContent(term) || value.getLabel().toLowerCase(Locale.ROOT).contains(term.toLowerCase(Locale.ROOT)))
{
values.add(value);
}
}
}
return (values);
}
/*******************************************************************************
** Resolve the table from the frontend form values, never from the config table.
*******************************************************************************/
private QTableMetaData resolveTable(Map<String, Serializable> otherValues)
{
if(otherValues == null || QContext.getQInstance() == null)
{
return (null);
}
String tableName = ValueUtils.getValueAsString(otherValues.get("table_name"));
return (StringUtils.hasContent(tableName) ? QContext.getQInstance().getTable(tableName) : null);
}
}
@@ -0,0 +1,384 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
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.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
import com.kingsrook.qqq.backend.core.model.metadata.tables.TablesPossibleValueSourceMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Visible Data Analysis tables and their enum sources, joins and child lists.
*******************************************************************************/
public final class DataAnalysisTables
{
public static final String TABLE_CONFIG = "data_analysis_table_config";
public static final String SAVED = "data_analysis_saved";
public static final String SAVED_STAR = "data_analysis_saved_star";
public static final String SCHEDULE = "data_analysis_schedule";
public static final String WATCH = "data_analysis_watch";
public static final String RUN = "data_analysis_run";
public static final String USER_KEY = "data_analysis_user";
public static final List<String> ALL = List.of(TABLE_CONFIG, SAVED, SAVED_STAR, SCHEDULE, WATCH, RUN);
public static final List<String> NAV_TABLES = List.of(SAVED, WATCH, TABLE_CONFIG, RUN);
private static final Map<String, List<String>> CHILD_COLUMNS = Map.of(
SAVED_STAR, List.of("user_id", "createDate"),
SCHEDULE, List.of("frequency", "enabled", "next_run_at", "last_status"),
WATCH, List.of("detector", "enabled", "state", "last_evaluated_at"));
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisTables()
{
}
/*******************************************************************************
** Standard CRUD permissions under the given base name.
*******************************************************************************/
public static QPermissionRules permissionRules(String baseName)
{
return (new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)
.withPermissionBaseName(baseName));
}
/*******************************************************************************
** The host supplies its authenticated user id as the value of this key.
*******************************************************************************/
public static QSecurityKeyType userKeyType()
{
return (new QSecurityKeyType().withName(USER_KEY).withAllAccessKeyName(DataAnalysisPermissions.ADMIN));
}
/*******************************************************************************
** All enum and table possible-value sources.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
List<QPossibleValueSource> sources = new ArrayList<>();
sources.add(new QPossibleValueSource().withName(DataAnalysisFieldPossibleValueProvider.NAME)
.withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING)
.withCustomCodeReference(new QCodeReference(DataAnalysisFieldPossibleValueProvider.class)));
sources.add(enumSource("kind", "COLUMN_PROFILE", "TABLE_PROFILE", "PIVOT", "COMPARE", "CORRELATION", "CROSSTAB", "SHIFT"));
sources.add(enumSource("chart_kind", "HISTOGRAM", "BOX", "BAR", "PARETO", "TIME_SERIES", "SCATTER", "HEATMAP"));
sources.add(enumSource("detector", "SPIKE", "DROP", "OUT_OF_RANGE", "NEW_CATEGORY", "NULL_RATE_CREEP"));
sources.add(enumSource("watch_state", "OK", "FIRING"));
sources.add(enumSource("watch_bucket", "HOUR", "DAY", "WEEK", "MONTH"));
sources.add(enumSource("watch_severity", "INFO", "WARNING", "ERROR", "CRITICAL"));
sources.add(enumSource("run_status", "RUNNING", "COMPLETE", "FAILED"));
sources.add(enumSource("schedule_frequency", "DAILY", "WEEKLY", "MONTHLY"));
sources.add(enumSource("schedule_format", "HTML_SUMMARY", "CSV", "XLSX"));
for(String table : ALL)
{
sources.add(QPossibleValueSource.newForTable(table));
}
return (sources);
}
/*******************************************************************************
** An enum source with stable string ids and readable labels.
*******************************************************************************/
private static QPossibleValueSource enumSource(String name, String... values)
{
List<QPossibleValue<?>> entries = new ArrayList<>();
for(String value : values)
{
entries.add(new QPossibleValue<>(value, label(value)));
}
return (new QPossibleValueSource().withName("data_analysis_" + name).withType(QPossibleValueSourceType.ENUM).withEnumValues(entries));
}
/*******************************************************************************
** Readable labels for snake case identifiers.
*******************************************************************************/
private static String label(String name)
{
StringBuilder result = new StringBuilder();
for(String word : name.toLowerCase(Locale.ROOT).split("_"))
{
result.append(result.isEmpty() ? "" : " ").append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return (result.toString());
}
/*******************************************************************************
** Base metadata; backendName identifies the connection, SQL names infer from table names.
*******************************************************************************/
private static QTableMetaData base(DataAnalysisQBitConfig config, String name, String title, String icon, String permissionBase)
{
return (new QTableMetaData().withName(name).withLabel(title).withIcon(new QIcon(icon))
.withBackendName(config.getBackendName()).withPrimaryKeyField("id").withPermissionRules(permissionRules(permissionBase))
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("ID").withIsEditable(false))
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withLabel("Created").withIsEditable(false))
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withLabel("Modified").withIsEditable(false)));
}
/*******************************************************************************
** A field with a readable label and bounded strings.
*******************************************************************************/
private static QFieldMetaData field(String name, QFieldType type)
{
QFieldMetaData field = new QFieldMetaData(name, type).withLabel(label(name));
if(type == QFieldType.STRING)
{
field.withMaxLength(250);
}
return (field);
}
/*******************************************************************************
** Owner-only reads and writes, with null owners denied.
*******************************************************************************/
private static RecordSecurityLock ownerLock(String field)
{
return (new RecordSecurityLock().withSecurityKeyType(USER_KEY).withFieldName(field)
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.DENY));
}
/*******************************************************************************
** The six tables in spec section 7.1.
*******************************************************************************/
public static List<QTableMetaData> tables(DataAnalysisQBitConfig config)
{
List<QTableMetaData> tables = new ArrayList<>();
tables.add(base(config, TABLE_CONFIG, "Analysis Settings", "settings", "data_analysis.settings")
.withRecordLabelFormatAndFields("%s", "table_name")
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME).withIsRequired(true))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(true))
.withField(field("max_rows", QFieldType.INTEGER))
.withField(field("sample_threshold_rows", QFieldType.INTEGER))
.withField(field("sample_rows", QFieldType.INTEGER))
.withField(field("min_group_size", QFieldType.INTEGER))
// add possibleValueSourceDependsOn("table_name") once core has it
.withField(field("analyzable_fields", QFieldType.TEXT).withPossibleValueSourceName(DataAnalysisFieldPossibleValueProvider.NAME))
// add possibleValueSourceDependsOn("table_name") once core has it
.withField(field("excluded_fields", QFieldType.TEXT).withPossibleValueSourceName(DataAnalysisFieldPossibleValueProvider.NAME))
.withField(field("cache_ttl_minutes", QFieldType.INTEGER))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withUniqueKey(new UniqueKey("table_name")));
tables.add(base(config, SAVED, "Saved Analyses", "insights", "data_analysis.saved")
.withRecordLabelFormatAndFields("%s", "name")
.withField(field("name", QFieldType.STRING).withIsRequired(true))
.withField(field("description", QFieldType.TEXT))
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME).withIsRequired(true))
.withField(field("filter_json", QFieldType.TEXT))
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("data_analysis_kind").withIsRequired(true))
.withField(field("definition_json", QFieldType.TEXT).withIsRequired(true))
.withField(field("chart_kind", QFieldType.STRING).withPossibleValueSourceName("data_analysis_chart_kind"))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withField(field("shared", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("read_owner_user_id", QFieldType.STRING).withIsEditable(false).withIsHidden(true))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withRecordSecurityLock(ownerLock("owner_user_id").withLockScope(RecordSecurityLock.LockScope.WRITE))
.withRecordSecurityLock(ownerLock("read_owner_user_id").withLockScope(RecordSecurityLock.LockScope.READ)
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.saved.SavedCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.saved.SavedCustomizer.class)));
tables.add(base(config, SAVED_STAR, "Stars", "star", "data_analysis.saved")
.withRecordLabelFormatAndFields("%s", "user_id")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved").withIsRequired(true))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withUniqueKey(new UniqueKey("saved_id", "user_id"))
.withRecordSecurityLock(ownerLock("user_id")));
tables.add(base(config, SCHEDULE, "Schedules", "schedule", "data_analysis.saved")
.withRecordLabelFormatAndFields("%s", "saved_id")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved").withIsRequired(true))
.withField(field("cron", QFieldType.STRING))
.withField(field("frequency", QFieldType.STRING).withPossibleValueSourceName("data_analysis_schedule_frequency"))
.withField(field("day", QFieldType.INTEGER))
.withField(field("time", QFieldType.STRING))
.withField(field("time_zone", QFieldType.STRING))
.withField(field("format", QFieldType.STRING).withDefaultValue("HTML_SUMMARY").withPossibleValueSourceName("data_analysis_schedule_format"))
.withField(field("recipients", QFieldType.TEXT))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("last_run_at", QFieldType.DATE_TIME))
.withField(field("last_status", QFieldType.TEXT).withIsEditable(false))
.withField(field("next_run_at", QFieldType.DATE_TIME))
.withRecordSecurityLock(ownerLock(SAVED + ".owner_user_id").withJoinNameChain(List.of(joinName(SCHEDULE))))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.schedule.ScheduleCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new QCodeReference(com.kingsrook.qbits.dataanalysis.schedule.ScheduleCustomizer.class)));
tables.add(base(config, WATCH, "Watches", "notifications_active", "data_analysis.watch")
.withRecordLabelFormatAndFields("%s", "detector")
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved")
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("table_name", QCriteriaOperator.EQUALS, "${input.table_name}"))))
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME))
.withField(field("filter_json", QFieldType.TEXT))
.withField(field("metric_json", QFieldType.TEXT))
.withField(field("detector", QFieldType.STRING).withPossibleValueSourceName("data_analysis_detector").withIsRequired(true))
.withField(field("parameters_json", QFieldType.TEXT))
.withField(field("date_field", QFieldType.STRING).withIsRequired(true))
.withField(field("bucket", QFieldType.STRING).withDefaultValue("DAY").withIsRequired(true).withPossibleValueSourceName("data_analysis_watch_bucket"))
.withField(field("severity", QFieldType.STRING).withDefaultValue("WARNING").withIsRequired(true).withPossibleValueSourceName("data_analysis_watch_severity"))
.withField(field("owner_user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("last_evaluated_at", QFieldType.DATE_TIME).withIsEditable(false))
.withField(field("last_value", QFieldType.DECIMAL).withIsEditable(false))
.withField(field("last_message", QFieldType.TEXT).withIsEditable(false))
.withField(field("state", QFieldType.STRING).withDefaultValue("OK").withIsEditable(false).withPossibleValueSourceName("data_analysis_watch_state"))
.withField(field("alert_key", QFieldType.STRING).withIsEditable(false))
.withRecordSecurityLock(ownerLock("owner_user_id"))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_INSERT_RECORD,
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.dataanalysis.watch.WatchCustomizer.class))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.PRE_UPDATE_RECORD,
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.dataanalysis.watch.WatchCustomizer.class)));
tables.add(base(config, RUN, "Analysis Runs", "history", "data_analysis.run")
.withRecordLabelFormatAndFields("%s", "operation")
.withField(field("table_name", QFieldType.STRING).withPossibleValueSourceName(TablesPossibleValueSourceMetaDataProvider.NAME).withIsRequired(true))
.withField(field("operation", QFieldType.STRING).withIsRequired(true))
.withField(field("request_hash", QFieldType.STRING))
.withField(field("filter_hash", QFieldType.STRING))
.withField(field("data_version", QFieldType.STRING))
.withField(field("status", QFieldType.STRING).withDefaultValue("RUNNING").withPossibleValueSourceName("data_analysis_run_status").withIsRequired(true))
.withField(field("started_at", QFieldType.DATE_TIME))
.withField(field("finished_at", QFieldType.DATE_TIME))
.withField(field("rows_scanned", QFieldType.LONG))
.withField(field("sampled", QFieldType.BOOLEAN).withDefaultValue(false))
.withField(field("result_json", QFieldType.TEXT))
.withField(field("error", QFieldType.TEXT))
.withField(field("user_id", QFieldType.STRING).withIsRequired(true).withBehavior(InsertingUserBehavior.INSERTING_USER))
.withRecordSecurityLock(ownerLock("user_id")));
for(QTableMetaData table : tables)
{
List<String> fields = new ArrayList<>(table.getFields().keySet());
fields.removeAll(List.of("id", "createDate", "modifyDate", "read_owner_user_id"));
int headlineSize = Math.min(4, fields.size());
table.withSection(SectionFactory.defaultT1(fields.subList(0, headlineSize).toArray(String[]::new)));
if(fields.size() > headlineSize)
{
table.withSection(new QFieldSection("details", "Details", new QIcon("list"), Tier.T2, fields.subList(headlineSize, fields.size())));
}
table.withSection(SectionFactory.defaultT3("id", "createDate", "modifyDate"));
QInstanceEnricher.setInferredFieldBackendNames(table);
if(SAVED.equals(table.getName()))
{
for(String child : List.of(SAVED_STAR, SCHEDULE, WATCH))
{
String title = child.equals(SAVED_STAR) ? "Stars" : child.equals(SCHEDULE) ? "Schedules" : "Watches";
table.withSection(new QFieldSection(joinName(child), new QIcon("list"), Tier.T2).withLabel(title).withWidgetName(joinName(child)));
table.withExposedJoin(new ExposedJoin().withLabel(title).withJoinTable(child).withJoinPath(List.of(joinName(child))));
}
}
}
return (tables);
}
/*******************************************************************************
** Stable names shared by joins, widgets and schedule locks.
*******************************************************************************/
public static String joinName(String child)
{
return (SAVED + "_to_" + child);
}
/*******************************************************************************
** Saved analyses link to their stars, schedules and watches.
*******************************************************************************/
public static List<QJoinMetaData> joins()
{
List<QJoinMetaData> joins = new ArrayList<>();
for(String child : List.of(SAVED_STAR, SCHEDULE, WATCH))
{
joins.add(new QJoinMetaData().withName(joinName(child)).withLeftTable(SAVED).withRightTable(child)
.withType(JoinType.ONE_TO_MANY).withJoinOn(new JoinOn("id", "saved_id")));
}
return (joins);
}
/*******************************************************************************
** Child lists use the standard renderer and at most four visible columns.
*******************************************************************************/
public static List<QWidgetMetaData> childListWidgets()
{
List<QWidgetMetaData> widgets = new ArrayList<>();
for(QJoinMetaData join : joins())
{
String child = join.getRightTable();
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getName())
.withLabel(child.equals(SAVED_STAR) ? "Stars" : child.equals(SCHEDULE) ? "Schedules" : "Watches")
.withMaxRows(10).withOnlyIncludeFieldNames(CHILD_COLUMNS.get(child)).getWidgetMetaData());
}
return (widgets);
}
}
@@ -0,0 +1,84 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.model;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** On INSERT only, an owner/user field left empty gets the inserting user's id,
** so a UI (which does not know the user's id) can create a saved analysis,
** star or watch. Unlike core DynamicDefaultValueBehavior.USER_ID it never
** applies on UPDATE, so an administrator's partial edit cannot take ownership.
** Naming another owner is still refused by the table customizers and locks.
*******************************************************************************/
public enum InsertingUserBehavior implements FieldBehavior<InsertingUserBehavior>
{
INSERTING_USER,
NONE;
/*******************************************************************************
** No behavior unless asked for.
*******************************************************************************/
@Override
public InsertingUserBehavior getDefault()
{
return (NONE);
}
/*******************************************************************************
** Fill empty values with the session user's id on insert.
*******************************************************************************/
@Override
public void apply(ValueBehaviorApplier.Action action, List<QRecord> recordList, QInstance instance, QTableMetaData table, QFieldMetaData field)
{
if(this == NONE || action != ValueBehaviorApplier.Action.INSERT)
{
return;
}
String userId = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getIdReference();
if(!StringUtils.hasContent(userId))
{
return;
}
for(QRecord record : CollectionUtils.nonNullList(recordList))
{
if(!StringUtils.hasContent(record.getValueString(field.getName())))
{
record.setValue(field.getName(), userId);
}
}
}
}
@@ -0,0 +1,74 @@
/*
* 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.permissions;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Data Analysis permissions through the core table feature registry.
*******************************************************************************/
public final class DataAnalysisPermissions
{
public static final String ADMIN = "data_analysis.admin";
public static final String SAVED_READ = "data_analysis.saved.read";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAnalysisPermissions()
{
}
/*******************************************************************************
** Stand-in, replace with core TableFeaturePermissionsMetaData (DATA-ANALYSIS.md §3.6).
** Replicates the package-private PermissionsHelper effective-base-name logic.
*******************************************************************************/
public static String analyzePermissionName(QTableMetaData table)
{
QPermissionRules rules = PermissionsHelper.getEffectivePermissionRules(table, QContext.getQInstance());
String base = rules != null && StringUtils.hasContent(rules.getPermissionBaseName()) ? rules.getPermissionBaseName() : table.getName();
return (base + ".analyze");
}
/*******************************************************************************
** Analysis always needs its explicit permission; READ follows instance enforcement.
*******************************************************************************/
public static boolean canAnalyze(QTableMetaData table)
{
boolean read = !Boolean.TRUE.equals(QContext.getQInstance().getEnforcePermissionsInCoreActions())
|| PermissionsHelper.hasTablePermission(new QueryInput(table.getName()), table.getName(), TablePermissionSubType.READ);
return (read && PermissionsHelper.hasTableFeaturePermission(QContext.getQInstance(), QContext.getQSession(), table, "analyze"));
}
}
@@ -0,0 +1,144 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.processes;
import java.time.Instant;
import java.util.ArrayList;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
/*******************************************************************************
** Standard streamed ETL Review and Result actions for scheduled analyses.
*******************************************************************************/
public final class ScheduleActions
{
public static final String SEND = "data_analysis_schedule_send";
public static final String SCHEDULED = "data_analysis_run_schedules";
/*******************************************************************************
** Steps only.
*******************************************************************************/
private ScheduleActions()
{
}
/*******************************************************************************
** Review has no delivery side effects.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Core extraction enforces record locks; verify the edit grant as well.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(!PermissionsHelper.hasTablePermission(new UpdateInput(DataAnalysisTables.SCHEDULE), DataAnalysisTables.SCHEDULE, TablePermissionSubType.EDIT))
{
throw new QPermissionDeniedException("Schedule editing is unavailable.");
}
for(QRecord record : input.getRecords())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Schedule " + record.getValueString("id") + " will be sent to authorized recipients."));
output.addRecord(record);
}
}
/*******************************************************************************
** Standard Review summary.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (result ? new ArrayList<>() : lines);
}
}
/*******************************************************************************
** Every selected record contributes its actual delivery status to Result.
*******************************************************************************/
public static class Result extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Reauthorize when executing after Review.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
try
{
String status = DataAnalysisRuntime.schedules().sendNow(record.getValue("id"), Instant.now());
lines.add(new ProcessSummaryLine(status.startsWith("Failed") ? Status.ERROR : Status.OK, null, status));
output.addRecord(record);
}
catch(QException e)
{
lines.add(new ProcessSummaryLine(Status.ERROR, null, e.getMessage()));
}
}
}
/*******************************************************************************
** Actual results across streamed pages.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (lines);
}
}
/*******************************************************************************
** Hidden scheduler every five minutes.
*******************************************************************************/
public static class Scheduled implements BackendStep
{
/*******************************************************************************
** Count actual due schedule attempts.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
output.addValue("count", DataAnalysisRuntime.schedules().runDue(Instant.now()));
}
}
}
@@ -0,0 +1,301 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Create rows for analyzable tables": a table action on Analysis Settings
** that writes one row per analyzable table that has none yet, holding the
** settings that already apply to it (code defaults for that table, then the
** global defaults). It changes no behavior on its own: it makes the defaults
** visible and editable. Existing rows are never touched. Standard streamed
** ETL: Review lists what will be added, Result what was added.
*******************************************************************************/
public final class TableConfigDefaultsProcess
{
public static final String NAME = "data_analysis_table_config_create_rows";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private TableConfigDefaultsProcess()
{
}
/*******************************************************************************
** The process: a table-level action (no records need to be selected). Only
** people who may insert Analysis Settings see it; the steps enforce the table
** permissions again.
*******************************************************************************/
public static QProcessMetaData process()
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, DataAnalysisTables.TABLE_CONFIG);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, DataAnalysisTables.TABLE_CONFIG);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
"These rows will be added: one per analyzable table that has none, holding the settings that already apply. Existing rows are not changed.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
process.setName(NAME);
process.setLabel("Create rows for analyzable tables");
process.setIcon(new QIcon("playlist_add"));
process.setTableName(DataAnalysisTables.TABLE_CONFIG);
process.setMinInputRecords(0);
process.setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(DataAnalysisTables.TABLE_CONFIG, TablePermissionSubType.INSERT)));
return (process);
}
/*******************************************************************************
** The tables analysis may be used on: everything but the qbit's own tables,
** limited by the includeTables and excludeTables configuration. The same test
** the "analyze" table permission uses.
*******************************************************************************/
public static List<String> analyzableTables(QInstance instance, DataAnalysisQBitConfig config)
{
return (instance.getTables().values().stream().map(QTableMetaData::getName)
.filter(name -> !name.startsWith(DataAnalysisQBitConfig.TABLE_NAME_PREFIX))
.filter(name -> config.getIncludeTables().isEmpty() || config.getIncludeTables().contains(name))
.filter(name -> !config.getExcludeTables().contains(name)).sorted().toList());
}
/*******************************************************************************
** The rows that are missing: one per analyzable table without a row, with
** the effective settings (the same values EffectiveConfigResolver produces).
*******************************************************************************/
public static List<QRecord> missingRows(QInstance instance, DataAnalysisQBitConfig config) throws QException
{
Set<String> existing = existingTables();
DataAnalysisRuntime.service().getConfigResolver().invalidate();
List<QRecord> rows = new ArrayList<>();
for(String table : analyzableTables(instance, config))
{
if(!existing.contains(table))
{
EffectiveConfig c = DataAnalysisRuntime.service().getConfigResolver().resolve(table);
rows.add(new QRecord().withValue("table_name", table).withValue("enabled", c.getEnabled()).withValue("max_rows", c.getMaxRows())
.withValue("sample_threshold_rows", c.getSampleThresholdRows()).withValue("sample_rows", c.getSampleRows())
.withValue("min_group_size", c.getMinGroupSize()).withValue("cache_ttl_minutes", c.getCacheTtlMinutes())
.withValue("analyzable_fields", c.getAnalyzableFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getAnalyzableFields())))
.withValue("excluded_fields", c.getExcludedFields().isEmpty() ? null : String.join(",", new TreeSet<>(c.getExcludedFields()))));
}
}
return (rows);
}
/*******************************************************************************
** Table names that already have a settings row (read as the signed-in user).
*******************************************************************************/
private static Set<String> existingTables() throws QException
{
QueryInput input = new QueryInput(DataAnalysisTables.TABLE_CONFIG).withFilter(new QQueryFilter());
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
Set<String> existing = new HashSet<>();
new QueryAction().execute(input).getRecords().forEach(row -> existing.add(row.getValueString("table_name")));
return (existing);
}
/*******************************************************************************
** Extract: the missing rows, computed (no query of the selected records).
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input) throws QException
{
return (missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()).size());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord row : missingRows(QContext.getQInstance(), DataAnalysisRuntime.service().getConfigResolver().getDefaults()))
{
getRecordPipe().addRecord(row);
}
}
}
/*******************************************************************************
** Transform: lists what will be added for the review screen.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine added = line(Status.OK, "table will get a settings row", "tables will get a settings row",
"table got a settings row", "tables got a settings row");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
added.incrementCount();
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
added.addSelfToListIfAnyCount(lines);
if(lines.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every analyzable table already has a settings row."));
}
return (lines);
}
}
/*******************************************************************************
** Load: insert through the standard action with the user's own permissions.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine created = line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
InsertInput insert = new InsertInput(DataAnalysisTables.TABLE_CONFIG).withRecords(input.getRecords());
insert.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
for(QRecord record : new InsertAction().execute(insert).getRecords())
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw (new QException("Could not add analysis settings: " + record.getErrors()));
}
created.incrementCount();
output.getRecords().add(record);
}
DataAnalysisRuntime.service().getConfigResolver().invalidate();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
created.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** A summary line with its four messages.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
{
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
line.setSingularFutureMessage(singularFuture);
line.setPluralFutureMessage(pluralFuture);
line.setSingularPastMessage(singularPast);
line.setPluralPastMessage(pluralPast);
return (line);
}
}
@@ -0,0 +1,186 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.processes;
import java.time.Instant;
import java.util.ArrayList;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
/*******************************************************************************
** Ordinary streamed ETL Review/Result steps; no custom forms or navigation.
*******************************************************************************/
public final class WatchActions
{
public static final String EVALUATE = "data_analysis_watch_evaluate";
public static final String ENABLE = "data_analysis_watch_enable";
public static final String DISABLE = "data_analysis_watch_disable";
public static final String SCHEDULED = "data_analysis_evaluate_watches";
/*******************************************************************************
** Steps only.
*******************************************************************************/
private WatchActions()
{
}
/*******************************************************************************
** Preview selected records without evaluating or mutating them.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Extract runs as caller; enforce EDIT before promising any action.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(!PermissionsHelper.hasTablePermission(new UpdateInput(DataAnalysisTables.WATCH), DataAnalysisTables.WATCH, TablePermissionSubType.EDIT))
{
throw new QPermissionDeniedException("Watch editing is unavailable.");
}
String verb = verb(input.getProcessName());
for(QRecord record : input.getRecords())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Watch " + record.getValueString("id") + " (" + record.getValueString("detector") + ") will be " + verb + "."));
output.addRecord(record);
}
}
/*******************************************************************************
** Standard summary lines are rendered by the core Review screen.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
return (result ? new ArrayList<>() : lines);
}
}
/*******************************************************************************
** Apply selected records and report exact evaluated/firing/OK/skipped counts.
*******************************************************************************/
public static class Result extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private int done;
private int firing;
private int ok;
private String process;
private final ArrayList<ProcessSummaryLineInterface> failures = new ArrayList<>();
/*******************************************************************************
** Re-read and authorize each id when executing after Review.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
process = input.getProcessName();
verb(process);
for(QRecord record : input.getRecords())
{
try
{
if(EVALUATE.equals(process))
{
var verdict = DataAnalysisRuntime.watches().evaluate(record.getValue("id"), Instant.now());
if(!verdict.evaluated())
{
failures.add(new ProcessSummaryLine(Status.WARNING, null, "Watch " + record.getValueString("id") + " skipped: " + verdict.message()));
continue;
}
firing += verdict.firing() ? 1 : 0;
ok += verdict.firing() ? 0 : 1;
}
else
{
DataAnalysisRuntime.watches().setEnabled(record.getValue("id"), ENABLE.equals(process));
}
done++;
output.addRecord(record);
}
catch(QException e)
{
failures.add(new ProcessSummaryLine(Status.ERROR, null, "Watch " + record.getValueString("id") + " failed: " + e.getMessage()));
}
}
}
/*******************************************************************************
** Counts span all streamed pages, so the Result screen is a true total.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
String message = done + (done == 1 ? " watch " : " watches ") + (EVALUATE.equals(process) ? "evaluated: " + firing + " firing, " + ok + " OK"
: ENABLE.equals(process) ? "enabled" : "disabled");
lines.add(new ProcessSummaryLine(Status.OK, null, message));
lines.addAll(failures);
return (lines);
}
}
/*******************************************************************************
** Scheduler invokes a hidden backend-only process every fifteen minutes.
*******************************************************************************/
public static class Scheduled implements BackendStep
{
/*******************************************************************************
** Store the real count of successfully evaluated enabled watches.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
output.addValue("count", DataAnalysisRuntime.watches().evaluateEnabled(Instant.now()));
}
}
/*******************************************************************************
** Reject unknown processes instead of silently choosing a mutation.
*******************************************************************************/
private static String verb(String process) throws QException
{
return (switch(process)
{
case EVALUATE -> "evaluated";
case ENABLE -> "enabled";
case DISABLE -> "disabled";
default -> throw new QException("Unknown watch action.");
});
}
}
@@ -0,0 +1,48 @@
/*
* 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.result;
import java.io.Serializable;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Common analysis request contract.
*******************************************************************************/
public interface AnalysisRequest extends Serializable
{
/*******************************************************************************
** Request getTableName.
*******************************************************************************/
String getTableName();
/*******************************************************************************
** Request getFilter.
*******************************************************************************/
QQueryFilter getFilter();
/*******************************************************************************
** Request getExact.
*******************************************************************************/
Boolean getExact();
}
@@ -0,0 +1,100 @@
/*
* 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.result;
import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed Axis contract; independent of any presentation framework.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class Axis implements Serializable
{
private String field;
private String label;
private ValueType valueType;
/*******************************************************************************
** Getter for field.
*******************************************************************************/
public String getField()
{
return (field);
}
/*******************************************************************************
** Fluent setter for field.
*******************************************************************************/
public Axis withField(String field)
{
this.field = field;
return (this);
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Fluent setter for label.
*******************************************************************************/
public Axis withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Getter for valueType.
*******************************************************************************/
public ValueType getValueType()
{
return (valueType);
}
/*******************************************************************************
** Fluent setter for valueType.
*******************************************************************************/
public Axis withValueType(ValueType valueType)
{
this.valueType = valueType;
return (this);
}
}
@@ -0,0 +1,101 @@
/*
* 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.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed CandidateKey contract; independent of any presentation framework.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class CandidateKey implements Serializable
{
private List<String> fields;
private Long rowCount;
private Drill drill;
/*******************************************************************************
** Getter for fields.
*******************************************************************************/
public List<String> getFields()
{
return (fields);
}
/*******************************************************************************
** Fluent setter for fields.
*******************************************************************************/
public CandidateKey withFields(List<String> fields)
{
this.fields = fields;
return (this);
}
/*******************************************************************************
** Getter for rowCount.
*******************************************************************************/
public Long getRowCount()
{
return (rowCount);
}
/*******************************************************************************
** Fluent setter for rowCount.
*******************************************************************************/
public CandidateKey withRowCount(Long rowCount)
{
this.rowCount = rowCount;
return (this);
}
/*******************************************************************************
** Getter for drill.
*******************************************************************************/
public Drill getDrill()
{
return (drill);
}
/*******************************************************************************
** Fluent setter for drill.
*******************************************************************************/
public CandidateKey withDrill(Drill drill)
{
this.drill = drill;
return (this);
}
}
@@ -0,0 +1,123 @@
/*
* 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.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed Capabilities contract; independent of any presentation framework.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class Capabilities implements Serializable
{
private String tableName;
private List<String> fields;
private com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig config;
private String backend;
/*******************************************************************************
** Getter for tableName.
*******************************************************************************/
public String getTableName()
{
return (tableName);
}
/*******************************************************************************
** Fluent setter for tableName.
*******************************************************************************/
public Capabilities withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for fields.
*******************************************************************************/
public List<String> getFields()
{
return (fields);
}
/*******************************************************************************
** Fluent setter for fields.
*******************************************************************************/
public Capabilities withFields(List<String> fields)
{
this.fields = fields;
return (this);
}
/*******************************************************************************
** Getter for config.
*******************************************************************************/
public com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Fluent setter for config.
*******************************************************************************/
public Capabilities withConfig(com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig config)
{
this.config = config;
return (this);
}
/*******************************************************************************
** Getter for backend.
*******************************************************************************/
public String getBackend()
{
return (backend);
}
/*******************************************************************************
** Fluent setter for backend.
*******************************************************************************/
public Capabilities withBackend(String backend)
{
this.backend = backend;
return (this);
}
}
@@ -0,0 +1,185 @@
/*
* 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.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed Chart contract; independent of any presentation framework.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class Chart implements Serializable
{
private ChartKind kind;
private Boolean stacked;
private List<ChartKind> alternatives = List.of();
private Axis x;
private Axis y;
private List<Series> series = List.of();
private List<String> annotations = List.of();
/*******************************************************************************
** Getter for kind.
*******************************************************************************/
public ChartKind getKind()
{
return (kind);
}
/*******************************************************************************
** Fluent setter for kind.
*******************************************************************************/
public Chart withKind(ChartKind kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Getter for alternatives.
*******************************************************************************/
public List<ChartKind> getAlternatives()
{
return (alternatives);
}
/*******************************************************************************
** Fluent setter for alternatives.
*******************************************************************************/
public Chart withAlternatives(List<ChartKind> alternatives)
{
this.alternatives = alternatives;
return (this);
}
/*******************************************************************************
** Getter for x.
*******************************************************************************/
public Axis getX()
{
return (x);
}
/*******************************************************************************
** Fluent setter for x.
*******************************************************************************/
public Chart withX(Axis x)
{
this.x = x;
return (this);
}
/*******************************************************************************
** Getter for y.
*******************************************************************************/
public Axis getY()
{
return (y);
}
/*******************************************************************************
** Fluent setter for y.
*******************************************************************************/
public Chart withY(Axis y)
{
this.y = y;
return (this);
}
/*******************************************************************************
** Getter for series.
*******************************************************************************/
public List<Series> getSeries()
{
return (series);
}
/*******************************************************************************
** Fluent setter for series.
*******************************************************************************/
public Chart withSeries(List<Series> series)
{
this.series = series;
return (this);
}
/*******************************************************************************
** Getter for annotations.
*******************************************************************************/
public List<String> getAnnotations()
{
return (annotations);
}
/*******************************************************************************
** Fluent setter for annotations.
*******************************************************************************/
public Chart withAnnotations(List<String> annotations)
{
this.annotations = annotations;
return (this);
}
/*******************************************************************************
** Whether category series should stack to a total share of one.
*******************************************************************************/
public Boolean getStacked()
{
return (stacked);
}
/*******************************************************************************
** Declare stacked shares explicitly rather than relying on chart prose.
*******************************************************************************/
public Chart withStacked(Boolean stacked)
{
this.stacked = stacked;
return (this);
}
}
@@ -0,0 +1,30 @@
/*
* 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.result;
/*******************************************************************************
** Semantic ChartKind values.
*******************************************************************************/
public enum ChartKind
{
HISTOGRAM, BOX, BAR, PARETO, TIME_SERIES, SCATTER, HEATMAP
}
@@ -0,0 +1,455 @@
/*
* 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.result;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Typed ColumnProfile contract; independent of any presentation framework.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class ColumnProfile implements Serializable
{
private String tableName;
private QQueryFilter filter;
private Boolean estimate = false;
private Long sampleRows = 0L;
private Boolean truncated = false;
private Long rowsScanned = 0L;
private Instant asOf;
private String dataVersion;
private List<String> warnings = new java.util.ArrayList<>();
private String fieldName;
private String fieldLabel;
private String fieldType;
private List<Metric> counts = new java.util.ArrayList<>();
private NumericProfile numeric;
private DateProfile dates;
private TextProfile text;
private List<Metric> booleans;
private List<ValueCount> topValues = List.of();
private Chart chart;
/*******************************************************************************
** Getter for tableName.
*******************************************************************************/
public String getTableName()
{
return (tableName);
}
/*******************************************************************************
** Fluent setter for tableName.
*******************************************************************************/
public ColumnProfile withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for filter.
*******************************************************************************/
public QQueryFilter getFilter()
{
return (filter);
}
/*******************************************************************************
** Fluent setter for filter.
*******************************************************************************/
public ColumnProfile withFilter(QQueryFilter filter)
{
this.filter = filter;
return (this);
}
/*******************************************************************************
** Getter for estimate.
*******************************************************************************/
public Boolean getEstimate()
{
return (estimate);
}
/*******************************************************************************
** Fluent setter for estimate.
*******************************************************************************/
public ColumnProfile withEstimate(Boolean estimate)
{
this.estimate = estimate;
return (this);
}
/*******************************************************************************
** Getter for sampleRows.
*******************************************************************************/
public Long getSampleRows()
{
return (sampleRows);
}
/*******************************************************************************
** Fluent setter for sampleRows.
*******************************************************************************/
public ColumnProfile withSampleRows(Long sampleRows)
{
this.sampleRows = sampleRows;
return (this);
}
/*******************************************************************************
** Getter for truncated.
*******************************************************************************/
public Boolean getTruncated()
{
return (truncated);
}
/*******************************************************************************
** Fluent setter for truncated.
*******************************************************************************/
public ColumnProfile withTruncated(Boolean truncated)
{
this.truncated = truncated;
return (this);
}
/*******************************************************************************
** Getter for rowsScanned.
*******************************************************************************/
public Long getRowsScanned()
{
return (rowsScanned);
}
/*******************************************************************************
** Fluent setter for rowsScanned.
*******************************************************************************/
public ColumnProfile withRowsScanned(Long rowsScanned)
{
this.rowsScanned = rowsScanned;
return (this);
}
/*******************************************************************************
** Getter for asOf.
*******************************************************************************/
public Instant getAsOf()
{
return (asOf);
}
/*******************************************************************************
** Fluent setter for asOf.
*******************************************************************************/
public ColumnProfile withAsOf(Instant asOf)
{
this.asOf = asOf;
return (this);
}
/*******************************************************************************
** Getter for dataVersion.
*******************************************************************************/
public String getDataVersion()
{
return (dataVersion);
}
/*******************************************************************************
** Fluent setter for dataVersion.
*******************************************************************************/
public ColumnProfile withDataVersion(String dataVersion)
{
this.dataVersion = dataVersion;
return (this);
}
/*******************************************************************************
** Getter for warnings.
*******************************************************************************/
public List<String> getWarnings()
{
return (warnings);
}
/*******************************************************************************
** Fluent setter for warnings.
*******************************************************************************/
public ColumnProfile withWarnings(List<String> warnings)
{
this.warnings = warnings;
return (this);
}
/*******************************************************************************
** Getter for fieldName.
*******************************************************************************/
public String getFieldName()
{
return (fieldName);
}
/*******************************************************************************
** Fluent setter for fieldName.
*******************************************************************************/
public ColumnProfile withFieldName(String fieldName)
{
this.fieldName = fieldName;
return (this);
}
/*******************************************************************************
** Getter for fieldLabel.
*******************************************************************************/
public String getFieldLabel()
{
return (fieldLabel);
}
/*******************************************************************************
** Fluent setter for fieldLabel.
*******************************************************************************/
public ColumnProfile withFieldLabel(String fieldLabel)
{
this.fieldLabel = fieldLabel;
return (this);
}
/*******************************************************************************
** Getter for fieldType.
*******************************************************************************/
public String getFieldType()
{
return (fieldType);
}
/*******************************************************************************
** Fluent setter for fieldType.
*******************************************************************************/
public ColumnProfile withFieldType(String fieldType)
{
this.fieldType = fieldType;
return (this);
}
/*******************************************************************************
** Getter for counts.
*******************************************************************************/
public List<Metric> getCounts()
{
return (counts);
}
/*******************************************************************************
** Fluent setter for counts.
*******************************************************************************/
public ColumnProfile withCounts(List<Metric> counts)
{
this.counts = counts;
return (this);
}
/*******************************************************************************
** Getter for numeric.
*******************************************************************************/
public NumericProfile getNumeric()
{
return (numeric);
}
/*******************************************************************************
** Fluent setter for numeric.
*******************************************************************************/
public ColumnProfile withNumeric(NumericProfile numeric)
{
this.numeric = numeric;
return (this);
}
/*******************************************************************************
** Getter for dates.
*******************************************************************************/
public DateProfile getDates()
{
return (dates);
}
/*******************************************************************************
** Fluent setter for dates.
*******************************************************************************/
public ColumnProfile withDates(DateProfile dates)
{
this.dates = dates;
return (this);
}
/*******************************************************************************
** Getter for text.
*******************************************************************************/
public TextProfile getText()
{
return (text);
}
/*******************************************************************************
** Fluent setter for text.
*******************************************************************************/
public ColumnProfile withText(TextProfile text)
{
this.text = text;
return (this);
}
/*******************************************************************************
** Getter for booleans.
*******************************************************************************/
public List<Metric> getBooleans()
{
return (booleans);
}
/*******************************************************************************
** Fluent setter for booleans.
*******************************************************************************/
public ColumnProfile withBooleans(List<Metric> booleans)
{
this.booleans = booleans;
return (this);
}
/*******************************************************************************
** Getter for topValues.
*******************************************************************************/
public List<ValueCount> getTopValues()
{
return (topValues);
}
/*******************************************************************************
** Fluent setter for topValues.
*******************************************************************************/
public ColumnProfile withTopValues(List<ValueCount> topValues)
{
this.topValues = topValues;
return (this);
}
/*******************************************************************************
** Getter for chart.
*******************************************************************************/
public Chart getChart()
{
return (chart);
}
/*******************************************************************************
** Fluent setter for chart.
*******************************************************************************/
public ColumnProfile withChart(Chart chart)
{
this.chart = chart;
return (this);
}
}
@@ -0,0 +1,122 @@
/*
* 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.result;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Typed ColumnProfileRequest contract; independent of any presentation framework.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class ColumnProfileRequest implements AnalysisRequest
{
private String tableName;
private QQueryFilter filter;
private Boolean exact = false;
private String fieldName;
/*******************************************************************************
** Getter for tableName.
*******************************************************************************/
public String getTableName()
{
return (tableName);
}
/*******************************************************************************
** Fluent setter for tableName.
*******************************************************************************/
public ColumnProfileRequest withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for filter.
*******************************************************************************/
public QQueryFilter getFilter()
{
return (filter);
}
/*******************************************************************************
** Fluent setter for filter.
*******************************************************************************/
public ColumnProfileRequest withFilter(QQueryFilter filter)
{
this.filter = filter;
return (this);
}
/*******************************************************************************
** Getter for exact.
*******************************************************************************/
public Boolean getExact()
{
return (exact);
}
/*******************************************************************************
** Fluent setter for exact.
*******************************************************************************/
public ColumnProfileRequest withExact(Boolean exact)
{
this.exact = exact;
return (this);
}
/*******************************************************************************
** Getter for fieldName.
*******************************************************************************/
public String getFieldName()
{
return (fieldName);
}
/*******************************************************************************
** Fluent setter for fieldName.
*******************************************************************************/
public ColumnProfileRequest withFieldName(String fieldName)
{
this.fieldName = fieldName;
return (this);
}
}
@@ -0,0 +1,213 @@
/*
* 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.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Typed CompareRequest contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class CompareRequest implements AnalysisRequest
{
private String tableName;
private QQueryFilter filter;
private Boolean exact = true;
private QQueryFilter filterA;
private QQueryFilter filterB;
private String labelA = "A";
private String labelB = "B";
private List<String> fields;
/*******************************************************************************
** Getter for tableName.
*******************************************************************************/
@Override
public String getTableName()
{
return (tableName);
}
/*******************************************************************************
** Fluent setter for tableName.
*******************************************************************************/
public CompareRequest withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for filter.
*******************************************************************************/
@Override
public QQueryFilter getFilter()
{
return (filter);
}
/*******************************************************************************
** Fluent setter for filter.
*******************************************************************************/
public CompareRequest withFilter(QQueryFilter filter)
{
this.filter = filter;
return (this);
}
/*******************************************************************************
** Getter for exact.
*******************************************************************************/
@Override
public Boolean getExact()
{
return (exact);
}
/*******************************************************************************
** Fluent setter for exact.
*******************************************************************************/
public CompareRequest withExact(Boolean exact)
{
this.exact = exact;
return (this);
}
/*******************************************************************************
** Getter for filterA.
*******************************************************************************/
public QQueryFilter getFilterA()
{
return (filterA);
}
/*******************************************************************************
** Fluent setter for filterA.
*******************************************************************************/
public CompareRequest withFilterA(QQueryFilter filterA)
{
this.filterA = filterA;
return (this);
}
/*******************************************************************************
** Getter for filterB.
*******************************************************************************/
public QQueryFilter getFilterB()
{
return (filterB);
}
/*******************************************************************************
** Fluent setter for filterB.
*******************************************************************************/
public CompareRequest withFilterB(QQueryFilter filterB)
{
this.filterB = filterB;
return (this);
}
/*******************************************************************************
** Getter for labelA.
*******************************************************************************/
public String getLabelA()
{
return (labelA);
}
/*******************************************************************************
** Fluent setter for labelA.
*******************************************************************************/
public CompareRequest withLabelA(String labelA)
{
this.labelA = labelA;
return (this);
}
/*******************************************************************************
** Getter for labelB.
*******************************************************************************/
public String getLabelB()
{
return (labelB);
}
/*******************************************************************************
** Fluent setter for labelB.
*******************************************************************************/
public CompareRequest withLabelB(String labelB)
{
this.labelB = labelB;
return (this);
}
/*******************************************************************************
** Getter for fields.
*******************************************************************************/
public List<String> getFields()
{
return (fields);
}
/*******************************************************************************
** Fluent setter for fields.
*******************************************************************************/
public CompareRequest withFields(List<String> fields)
{
this.fields = fields;
return (this);
}
}
@@ -0,0 +1,55 @@
/*
* 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.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed CompareResult contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class CompareResult implements Serializable
{
private List<FieldComparison> fields = new java.util.ArrayList<>();
/*******************************************************************************
** Getter for fields.
*******************************************************************************/
public List<FieldComparison> getFields()
{
return (fields);
}
/*******************************************************************************
** Fluent setter for fields.
*******************************************************************************/
public CompareResult withFields(List<FieldComparison> fields)
{
this.fields = fields;
return (this);
}
}
@@ -0,0 +1,77 @@
/*
* 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.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed ComparisonSide contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class ComparisonSide implements Serializable
{
private String label;
private List<Metric> metrics = new java.util.ArrayList<>();
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Fluent setter for label.
*******************************************************************************/
public ComparisonSide withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Getter for metrics.
*******************************************************************************/
public List<Metric> getMetrics()
{
return (metrics);
}
/*******************************************************************************
** Fluent setter for metrics.
*******************************************************************************/
public ComparisonSide withMetrics(List<Metric> metrics)
{
this.metrics = metrics;
return (this);
}
}
@@ -0,0 +1,186 @@
/*
* 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.result;
import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed ComparisonValue contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class ComparisonValue implements Serializable
{
private Serializable value;
private String label;
private ComparisonSide sideA;
private ComparisonSide sideB;
private Metric delta;
private Metric percentDelta;
private Significance significance;
/*******************************************************************************
** Getter for value.
*******************************************************************************/
public Serializable getValue()
{
return (value);
}
/*******************************************************************************
** Fluent setter for value.
*******************************************************************************/
public ComparisonValue withValue(Serializable value)
{
this.value = value;
return (this);
}
/*******************************************************************************
** Getter for label.
*******************************************************************************/
public String getLabel()
{
return (label);
}
/*******************************************************************************
** Fluent setter for label.
*******************************************************************************/
public ComparisonValue withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Getter for sideA.
*******************************************************************************/
public ComparisonSide getSideA()
{
return (sideA);
}
/*******************************************************************************
** Fluent setter for sideA.
*******************************************************************************/
public ComparisonValue withSideA(ComparisonSide sideA)
{
this.sideA = sideA;
return (this);
}
/*******************************************************************************
** Getter for sideB.
*******************************************************************************/
public ComparisonSide getSideB()
{
return (sideB);
}
/*******************************************************************************
** Fluent setter for sideB.
*******************************************************************************/
public ComparisonValue withSideB(ComparisonSide sideB)
{
this.sideB = sideB;
return (this);
}
/*******************************************************************************
** Getter for delta.
*******************************************************************************/
public Metric getDelta()
{
return (delta);
}
/*******************************************************************************
** Fluent setter for delta.
*******************************************************************************/
public ComparisonValue withDelta(Metric delta)
{
this.delta = delta;
return (this);
}
/*******************************************************************************
** Getter for percentDelta.
*******************************************************************************/
public Metric getPercentDelta()
{
return (percentDelta);
}
/*******************************************************************************
** Fluent setter for percentDelta.
*******************************************************************************/
public ComparisonValue withPercentDelta(Metric percentDelta)
{
this.percentDelta = percentDelta;
return (this);
}
/*******************************************************************************
** Getter for significance.
*******************************************************************************/
public Significance getSignificance()
{
return (significance);
}
/*******************************************************************************
** Fluent setter for significance.
*******************************************************************************/
public ComparisonValue withSignificance(Significance significance)
{
this.significance = significance;
return (this);
}
}
@@ -0,0 +1,142 @@
/*
* 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.result;
import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed CorrelationCell contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class CorrelationCell implements Serializable
{
private String xField;
private String yField;
private Long n;
private Double r;
private ScatterRequest scatter;
/*******************************************************************************
** Getter for xField.
*******************************************************************************/
public String getXField()
{
return (xField);
}
/*******************************************************************************
** Fluent setter for xField.
*******************************************************************************/
public CorrelationCell withXField(String xField)
{
this.xField = xField;
return (this);
}
/*******************************************************************************
** Getter for yField.
*******************************************************************************/
public String getYField()
{
return (yField);
}
/*******************************************************************************
** Fluent setter for yField.
*******************************************************************************/
public CorrelationCell withYField(String yField)
{
this.yField = yField;
return (this);
}
/*******************************************************************************
** Getter for n.
*******************************************************************************/
public Long getN()
{
return (n);
}
/*******************************************************************************
** Fluent setter for n.
*******************************************************************************/
public CorrelationCell withN(Long n)
{
this.n = n;
return (this);
}
/*******************************************************************************
** Getter for r.
*******************************************************************************/
public Double getR()
{
return (r);
}
/*******************************************************************************
** Fluent setter for r.
*******************************************************************************/
public CorrelationCell withR(Double r)
{
this.r = r;
return (this);
}
/*******************************************************************************
** Getter for scatter.
*******************************************************************************/
public ScatterRequest getScatter()
{
return (scatter);
}
/*******************************************************************************
** Fluent setter for scatter.
*******************************************************************************/
public CorrelationCell withScatter(ScatterRequest scatter)
{
this.scatter = scatter;
return (this);
}
}
@@ -0,0 +1,125 @@
/*
* 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.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Typed CorrelationRequest contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class CorrelationRequest implements AnalysisRequest
{
private String tableName;
private QQueryFilter filter;
private Boolean exact = true;
private List<String> fields;
/*******************************************************************************
** Getter for tableName.
*******************************************************************************/
@Override
public String getTableName()
{
return (tableName);
}
/*******************************************************************************
** Fluent setter for tableName.
*******************************************************************************/
public CorrelationRequest withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for filter.
*******************************************************************************/
@Override
public QQueryFilter getFilter()
{
return (filter);
}
/*******************************************************************************
** Fluent setter for filter.
*******************************************************************************/
public CorrelationRequest withFilter(QQueryFilter filter)
{
this.filter = filter;
return (this);
}
/*******************************************************************************
** Getter for exact.
*******************************************************************************/
@Override
public Boolean getExact()
{
return (exact);
}
/*******************************************************************************
** Fluent setter for exact.
*******************************************************************************/
public CorrelationRequest withExact(Boolean exact)
{
this.exact = exact;
return (this);
}
/*******************************************************************************
** Getter for fields.
*******************************************************************************/
public List<String> getFields()
{
return (fields);
}
/*******************************************************************************
** Fluent setter for fields.
*******************************************************************************/
public CorrelationRequest withFields(List<String> fields)
{
this.fields = fields;
return (this);
}
}
@@ -0,0 +1,99 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis.result;
import java.io.Serializable;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed CorrelationResult contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class CorrelationResult implements Serializable
{
private List<String> fields;
private List<CorrelationCell> cells = new java.util.ArrayList<>();
private Chart chart;
/*******************************************************************************
** Getter for fields.
*******************************************************************************/
public List<String> getFields()
{
return (fields);
}
/*******************************************************************************
** Fluent setter for fields.
*******************************************************************************/
public CorrelationResult withFields(List<String> fields)
{
this.fields = fields;
return (this);
}
/*******************************************************************************
** Getter for cells.
*******************************************************************************/
public List<CorrelationCell> getCells()
{
return (cells);
}
/*******************************************************************************
** Fluent setter for cells.
*******************************************************************************/
public CorrelationResult withCells(List<CorrelationCell> cells)
{
this.cells = cells;
return (this);
}
/*******************************************************************************
** Getter for chart.
*******************************************************************************/
public Chart getChart()
{
return (chart);
}
/*******************************************************************************
** Fluent setter for chart.
*******************************************************************************/
public CorrelationResult withChart(Chart chart)
{
this.chart = chart;
return (this);
}
}
@@ -0,0 +1,142 @@
/*
* 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.result;
import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
/*******************************************************************************
** Typed CrosstabCell contract for comparison and relationship analysis.
*******************************************************************************/
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
public class CrosstabCell implements Serializable
{
private Integer rowIndex;
private Integer columnIndex;
private Metric count;
private Metric rowShare;
private Metric columnShare;
/*******************************************************************************
** Getter for rowIndex.
*******************************************************************************/
public Integer getRowIndex()
{
return (rowIndex);
}
/*******************************************************************************
** Fluent setter for rowIndex.
*******************************************************************************/
public CrosstabCell withRowIndex(Integer rowIndex)
{
this.rowIndex = rowIndex;
return (this);
}
/*******************************************************************************
** Getter for columnIndex.
*******************************************************************************/
public Integer getColumnIndex()
{
return (columnIndex);
}
/*******************************************************************************
** Fluent setter for columnIndex.
*******************************************************************************/
public CrosstabCell withColumnIndex(Integer columnIndex)
{
this.columnIndex = columnIndex;
return (this);
}
/*******************************************************************************
** Getter for count.
*******************************************************************************/
public Metric getCount()
{
return (count);
}
/*******************************************************************************
** Fluent setter for count.
*******************************************************************************/
public CrosstabCell withCount(Metric count)
{
this.count = count;
return (this);
}
/*******************************************************************************
** Getter for rowShare.
*******************************************************************************/
public Metric getRowShare()
{
return (rowShare);
}
/*******************************************************************************
** Fluent setter for rowShare.
*******************************************************************************/
public CrosstabCell withRowShare(Metric rowShare)
{
this.rowShare = rowShare;
return (this);
}
/*******************************************************************************
** Getter for columnShare.
*******************************************************************************/
public Metric getColumnShare()
{
return (columnShare);
}
/*******************************************************************************
** Fluent setter for columnShare.
*******************************************************************************/
public CrosstabCell withColumnShare(Metric columnShare)
{
this.columnShare = columnShare;
return (this);
}
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more