Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb8b431b7f
|
||
|
|
d8b52113ca
|
||
|
|
c90d6c9866
|
||
|
|
a91f6c5e03
|
||
|
|
1b9f502e23 | ||
|
|
261d0213ad
|
||
|
|
4cb5ad910e | ||
|
|
1076daacde
|
No files matched your search
+48
-9
@@ -1,21 +1,60 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.5.0
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
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
|
||||
|
||||
workflows:
|
||||
build:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.cmd text eol=crlf
|
||||
mvnw text eol=lf
|
||||
@@ -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: ['*']
|
||||
@@ -0,0 +1,25 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
+16
-15
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `data-analysis` UI API extension 1.0.0: seven session-bound profile, capability, distinct-value, duplicate and run endpoints, typed schemas and a checked OpenAPI fragment.
|
||||
- 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
|
||||
|
||||
- Scalable estimates sample each native aggregate without primary-key IN lists, scale counts using observed sample sizes, retain exact population totals and unscaled ratios, and drill over the full population. Portable estimates use one primary-key boundary and report unscaled prefix counts.
|
||||
|
||||
- Target QQQ 4.1.0-RC.6; local verification uses `-Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT`.
|
||||
- Empty-string and temporal drills round-trip through core HTTP filter interpretation.
|
||||
|
||||
### Previously added
|
||||
|
||||
- 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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code Style Guide
|
||||
|
||||
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,42 +1,195 @@
|
||||
# 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, distinct-value pages, duplicate groups, drill-through filters, effective per-table configuration, privacy suppression, and asynchronous exact 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
|
||||
Seven HTTP operations are available under `/qqq/v1/qbit/data-analysis`; the Analyze UI is a later phase. Pivots, comparisons, anomalies, saved-analysis execution, scheduling and exports remain later phases.
|
||||
|
||||
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.6+**.
|
||||
- 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
|
||||
## Tables
|
||||
|
||||
## Documentation
|
||||
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.
|
||||
|
||||
- [Getting Started](docs/00-getting-started.md)
|
||||
- [Extension Patterns](docs/01-extension-patterns.md)
|
||||
| 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.
|
||||
|
||||
## 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 READ_AND_WRITE lock on `owner_user_id`; stars and runs have locks on `user_id`. Schedules inherit the saved analysis owner's lock through their join. Null owners are denied. The host's authentication/session adapter must supply:
|
||||
|
||||
```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 deliberately restricted in this scaffold.** The ordinary owner lock does not express owner OR shared while keeping writes owner-only, so setting `shared=true` currently does not expose a saved analysis to other users. Phase f will introduce separate read and write policies: reads permit owner OR shared only after checking `data_analysis.saved.read`; writes retain owner/admin checks. Query, get, count, joins, child lists and exports will receive cross-user regression tests before enabling shared READ. Collaboration sharing and favorites (§7.3, qqq#1136) will then replace the local sharing/star contracts. Shared analyses must always execute as their viewer.
|
||||
|
||||
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.0.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/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` |
|
||||
|
||||
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 seven operations.
|
||||
|
||||
Regenerate deliberately after an API change:
|
||||
|
||||
```bash
|
||||
./mvnw -o -q test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
1. Add a backend, install the producer and run the usual `QInstanceValidator`.
|
||||
2. For PostgreSQL, obtain `DataAnalysisSchema.postgresScript(config)` and apply it through your application's migration system. It derives fields and unique indexes from table metadata. It does not execute DDL or upgrade existing schemas.
|
||||
3. Map user-id security keys and permissions in the authentication adapter. Configure core audit persistence if field audit history is needed.
|
||||
4. Review per-table include/exclude sets, limits and minimum group sizes before enabling analysis. Integrate Saved Analyses and Watches with host ESB/Quick Search when those optional host features are installed; this scaffold does not add those dependencies.
|
||||
5. Use the Analyses home as the owner of analysis data. Other dashboards should link there until saved-analysis widgets are implemented.
|
||||
|
||||
Phase **b** profiles, drill-through, runs/cache and HTTP endpoints are implemented. Remaining work: **b** analysis UI; **c** pivots; **d** comparisons and relationships; **e** watch evaluation and alerts; **f** saved-analysis execution, sharing, stars integration, schedules, chart widgets, full home and exports. The current home summarizes stored rows; the host facade executes profiles. The home does not send deliveries.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./mvnw -o -q verify -Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT
|
||||
```
|
||||
|
||||
The wrapper pins Maven 3.9.11. On Windows use `mvnw.cmd`. Verification runs Checkstyle, a shared JUnit 5/AssertJ contract suite on memory and H2, PostgreSQL via Testcontainers when Docker is available, and JaCoCo gates of 70% instructions and 90% classes. The suite checks numerical references, every exact count/drill pair, full-population estimate drills, security, guarded cache JSON, native sampling, asynchronous jobs and cache invalidation. The POM targets RC.6; until its release, always supply the locally installed snapshot override shown above. RDBMS, H2, Testcontainers PostgreSQL and the PostgreSQL driver are test-scoped; middleware Javalin is optional.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
[Apache License 2.0](LICENSE). Copyright 2026 Kingsrook, LLC. See [NOTICE](NOTICE).
|
||||
@@ -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").
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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. This repository phase supplies those tables, permissions, configuration, PostgreSQL schema generation and an Analyses home with real session-scoped counts; analytical computation and delivery are planned.
|
||||
|
||||
## 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 |
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
@@ -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-SNAPSHOT</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>
|
||||
@@ -45,14 +131,103 @@
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</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.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<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.6</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,390 @@
|
||||
/*
|
||||
* 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 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 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi;
|
||||
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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 = "0.1.0";
|
||||
|
||||
private DataAnalysisQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(instance);
|
||||
output.addSelfToInstance(instance);
|
||||
new DataAnalysisRuntime(qBitConfig).addSelfToInstance(instance);
|
||||
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()));
|
||||
}
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a runtime for one configured instance.
|
||||
*******************************************************************************/
|
||||
public DataAnalysisRuntime(DataAnalysisQBitConfig config)
|
||||
{
|
||||
service = new DataAnalysisService(config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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" : "")));
|
||||
}
|
||||
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 BOOLEAN -> "BOOLEAN";
|
||||
case DATE_TIME -> "TIMESTAMP WITHOUT TIME ZONE";
|
||||
case TEXT -> "TEXT";
|
||||
default -> "VARCHAR(" + field.getMaxLength() + ")";
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* 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.concurrent.Executor;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisSecurity;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.ColumnProfiler;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.CoreAnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.DistinctValues;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.DuplicateFinder;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfigResolver;
|
||||
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.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.RunStatus;
|
||||
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 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);
|
||||
security = new AnalysisSecurity(configs);
|
||||
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 (new DistinctValues(backend(request.getTableName())).page(request, security.field(request.getTableName(), request.getFieldName()), configs.resolve(request.getTableName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find guarded duplicate groups using only authorized fields.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult duplicates(DuplicatesRequest request) throws QException
|
||||
{
|
||||
validate(request);
|
||||
return (new DuplicateFinder(backend(request.getTableName())).find(request, configs.resolve(request.getTableName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 (new ColumnProfiler(backend).profile(request, security.field(request.getTableName(), request.getFieldName()), scope, configs.resolve(request.getTableName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 (new TableProfiler(backend).profile(request, fields, scope));
|
||||
}
|
||||
}
|
||||
@@ -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,507 @@
|
||||
/*
|
||||
* 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.DistinctValuesRequest;
|
||||
import com.kingsrook.qbits.dataanalysis.result.DuplicatesRequest;
|
||||
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 profile 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.0.0")
|
||||
.withDescription("Profiles, distinct values, duplicate groups and exact analysis runs as the signed-in user.")
|
||||
.withSpec(CapabilitiesSpec.class).withSpec(ColumnProfileSpec.class).withSpec(DistinctValuesSpec.class)
|
||||
.withSpec(TableProfileSpec.class).withSpec(DuplicatesSpec.class).withSpec(StartRunSpec.class).withSpec(GetRunSpec.class));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Common schema catalog and path parameters for the seven 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" : 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 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, readable);
|
||||
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, List<String> readable)
|
||||
{
|
||||
if(filter == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(var criterion : filter.getCriteria())
|
||||
{
|
||||
if(criterion.getOperator() == null || !readable.contains(criterion.getFieldName())
|
||||
|| criterion.getOtherFieldName() != null && !readable.contains(criterion.getOtherFieldName()))
|
||||
{
|
||||
throw new IllegalArgumentException("Choose readable filter fields and a valid operator.");
|
||||
}
|
||||
}
|
||||
filter.getSubFilters().forEach(sub -> validateFilter(sub, readable));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 = QContext.getQInstance().getApp("sqlWorkbench") != null
|
||||
|| QContext.getQInstance().getTables().keySet().stream().anyMatch(t -> t.startsWith("sql_workbench_"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisFormatting;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
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.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Analyses app home: real counts under the current user's permissions and locks.
|
||||
*******************************************************************************/
|
||||
public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final String HOME_WIDGET = "dataAnalysisHome";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home itself has no data privilege; each section checks its table READ.
|
||||
*******************************************************************************/
|
||||
public static QWidgetMetaData widget()
|
||||
{
|
||||
return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(DataAnalysisHome.class)).withGridColumns(12).withIsCard(false)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only main tables appear in navigation; schedules and stars are record children.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData app(DataAnalysisQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("insights")).withWidgets(List.of(HOME_WIDGET))
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RawHTML is carried as a JSON string by the HTML widget, not a text/html response.
|
||||
** Entities keep static separators independent of any host charset handling.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, render(Instant.now()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts are evaluated at one instant; no system session or synthetic numbers.
|
||||
*******************************************************************************/
|
||||
public static String render(Instant now) throws QException
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"data-analysis-home\" style=\"display:grid;gap:1rem;min-width:0;overflow-wrap:anywhere\">");
|
||||
String userId = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getIdReference();
|
||||
html.append("<section><h3>Saved analyses</h3>");
|
||||
if(userId == null)
|
||||
{
|
||||
html.append("<p>Sign in to see your saved analyses and stars.</p>");
|
||||
}
|
||||
else if(canRead(DataAnalysisTables.SAVED))
|
||||
{
|
||||
int mine = count(DataAnalysisTables.SAVED, equal("owner_user_id", userId));
|
||||
int shared = count(DataAnalysisTables.SAVED, equal("shared", true));
|
||||
html.append("<p>Mine: ").append(AnalysisFormatting.number(mine, 0)).append(" · Shared (visible to you): ").append(AnalysisFormatting.number(shared, 0)).append("</p>");
|
||||
if(canRead(DataAnalysisTables.SAVED_STAR))
|
||||
{
|
||||
CountInput stars = new CountInput(DataAnalysisTables.SAVED).withIncludeDistinctCount(true)
|
||||
.withQueryJoin(new QueryJoin(QContext.getQInstance().getJoin(DataAnalysisTables.joinName(DataAnalysisTables.SAVED_STAR))).withType(QueryJoin.Type.INNER))
|
||||
.withFilter(equal(DataAnalysisTables.SAVED_STAR + ".user_id", userId));
|
||||
stars.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
int starred = new CountAction().execute(stars).getDistinctCount();
|
||||
html.append("<p>Starred by me: ").append(AnalysisFormatting.number(starred, 0)).append("</p>");
|
||||
}
|
||||
if(mine == 0 && shared == 0)
|
||||
{
|
||||
html.append("<p>No saved analyses yet. Open any table and choose Analyze on a column header to explore its data.</p>");
|
||||
}
|
||||
link(html, DataAnalysisTables.SAVED);
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<p>Saved analyses are unavailable: you need data_analysis.saved.read.</p>");
|
||||
}
|
||||
html.append("</section><section><h3>Watches</h3>");
|
||||
if(canRead(DataAnalysisTables.WATCH))
|
||||
{
|
||||
int total = count(DataAnalysisTables.WATCH, new QQueryFilter());
|
||||
int enabled = count(DataAnalysisTables.WATCH, equal("enabled", true));
|
||||
int firing = count(DataAnalysisTables.WATCH, equal("state", "FIRING"));
|
||||
html.append("<p>Total: ").append(AnalysisFormatting.number(total, 0)).append(" · Enabled: ").append(AnalysisFormatting.number(enabled, 0)).append(" · Firing: ").append(AnalysisFormatting.number(firing, 0)).append("</p>");
|
||||
if(total == 0)
|
||||
{
|
||||
html.append("<p>No watches yet.</p>");
|
||||
}
|
||||
link(html, DataAnalysisTables.WATCH);
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<p>Watches are unavailable: you need data_analysis.watch.read.</p>");
|
||||
}
|
||||
html.append("</section><section><h3>Recent analysis runs</h3>");
|
||||
if(userId == null)
|
||||
{
|
||||
html.append("<p>Sign in to see your recent analysis runs.</p>");
|
||||
}
|
||||
else if(canRead(DataAnalysisTables.RUN))
|
||||
{
|
||||
QueryInput query = new QueryInput(DataAnalysisTables.RUN).withFilter(equal("user_id", userId)
|
||||
.withCriteria(new QFilterCriteria("started_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))
|
||||
.withOrderBy(new QFilterOrderBy("started_at", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(5));
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<QRecord> runs = new QueryAction().execute(query).getRecords();
|
||||
if(runs.isEmpty())
|
||||
{
|
||||
html.append("<p>No analysis runs yet.</p>");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<ul class=\"data-analysis-runs\">");
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(run.getValueString("table_name"));
|
||||
String tableLabel = table == null ? run.getValueString("table_name") : table.getLabel();
|
||||
String path = QContext.getQInstance().getTablePath(DataAnalysisTables.RUN) + "/" + run.getValueInteger("id");
|
||||
html.append("<li><a href=\"").append(escape(path)).append("\">").append(escape(tableLabel)).append(" · ")
|
||||
.append(escape(words(run.getValueString("operation")))).append("</a><dl class=\"data-analysis-run-details\"><dt>Status</dt><dd>")
|
||||
.append(escape(words(run.getValueString("status")))).append("</dd><dt>Finished</dt><dd>")
|
||||
.append(run.getValue("finished_at") == null ? "Not finished" : escape(AnalysisFormatting.instant(run.getValueInstant("finished_at"))))
|
||||
.append("</dd></dl></li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
}
|
||||
link(html, DataAnalysisTables.RUN);
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<p>Run history is unavailable: you need data_analysis.run.read.</p>");
|
||||
}
|
||||
return (html.append("</section></div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit permission checks avoid presenting denied sections as empty data.
|
||||
*******************************************************************************/
|
||||
private static boolean canRead(String table)
|
||||
{
|
||||
return (PermissionsHelper.hasTablePermission(new CountInput(table), table, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Equality filter for one count.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter equal(String field, java.io.Serializable value)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stored operation and status keys are displayed as ordinary words.
|
||||
*******************************************************************************/
|
||||
private static String words(String value)
|
||||
{
|
||||
return (value == null ? "Unknown" : Arrays.stream(value.toLowerCase(Locale.ROOT).split("_"))
|
||||
.filter(part -> !part.isEmpty()).map(part -> Character.toUpperCase(part.charAt(0)) + part.substring(1)).collect(Collectors.joining(" ")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata and persisted values must never become executable widget markup.
|
||||
*******************************************************************************/
|
||||
private static String escape(String value)
|
||||
{
|
||||
return (value == null ? "Unknown" : value.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">").replace("'", "'"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CountAction enforces table permission and record locks using QContext's session.
|
||||
*******************************************************************************/
|
||||
private static int count(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
CountInput input = new CountInput(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new CountAction().execute(input).getCount());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the instance's actual app path, including custom names and parent apps.
|
||||
*******************************************************************************/
|
||||
private static void link(StringBuilder html, String table) throws QException
|
||||
{
|
||||
String path = QContext.getQInstance().getTablePath(table);
|
||||
html.append("<a href=\"").append(escape(path))
|
||||
.append("\">View all</a>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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,179 @@
|
||||
/*
|
||||
* 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.FieldSecurityLockHelper;
|
||||
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, new LinkedHashSet<>()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve nested PVS label fields without invoking core's trusted lookup path.
|
||||
*******************************************************************************/
|
||||
private static String translate(QFieldMetaData field, Serializable value, Set<String> visited) throws QException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
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) || !FieldSecurityLockHelper.isFieldAccessible(table.getField(labelField)))
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
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("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,94 @@
|
||||
/*
|
||||
* 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.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.FieldSecurityLockHelper;
|
||||
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.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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail closed before any source-table computation, including cache retrieval.
|
||||
*******************************************************************************/
|
||||
public class AnalysisSecurity
|
||||
{
|
||||
private final EffectiveConfigResolver configs;
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the runtime's configuration policy.
|
||||
*******************************************************************************/
|
||||
public AnalysisSecurity(EffectiveConfigResolver configs)
|
||||
{
|
||||
this.configs = configs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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(FieldSecurityLockHelper::isFieldAccessible)
|
||||
.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 QPermissionDeniedException("Analysis is unavailable for this field.")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend can be replaced when core's new operators ship.
|
||||
*******************************************************************************/
|
||||
public ColumnProfiler(AnalysisBackend backend)
|
||||
{
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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));
|
||||
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,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,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.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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind primitive operations.
|
||||
*******************************************************************************/
|
||||
public DistinctValues(AnalysisBackend backend)
|
||||
{
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
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,171 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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,65 @@
|
||||
/*
|
||||
* 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("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,146 @@
|
||||
/*
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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)
|
||||
{
|
||||
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() - 2;
|
||||
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 >= 10 && count <= 25 && (best == null || Math.abs(count - 20) < Math.abs(best.count() - 20)))
|
||||
{
|
||||
best = new NiceBins(first, last, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (java.util.Objects.requireNonNull(best));
|
||||
}
|
||||
}
|
||||
@@ -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.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
|
||||
{
|
||||
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());
|
||||
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,784 @@
|
||||
/*
|
||||
* 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.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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 (new AggregateAction().execute(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)));
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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());
|
||||
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);
|
||||
}
|
||||
output.add(DataAnalysisHome.widget());
|
||||
output.add(DataAnalysisHome.app(getQBitConfig(), tables));
|
||||
return (output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* 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.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
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.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(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("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).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))
|
||||
.withField(field("analyzable_fields", QFieldType.TEXT))
|
||||
.withField(field("excluded_fields", QFieldType.TEXT))
|
||||
.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).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))
|
||||
.withField(field("shared", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withRecordSecurityLock(ownerLock("owner_user_id")));
|
||||
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))
|
||||
.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.STRING).withPossibleValueSourceName("data_analysis_run_status"))
|
||||
.withField(field("next_run_at", QFieldType.DATE_TIME))
|
||||
.withRecordSecurityLock(ownerLock(SAVED + ".owner_user_id").withJoinNameChain(List.of(joinName(SCHEDULE)))));
|
||||
tables.add(base(config, WATCH, "Watches", "notifications_active", "data_analysis.watch")
|
||||
.withRecordLabelFormatAndFields("%s", "detector")
|
||||
.withField(field("saved_id", QFieldType.INTEGER).withPossibleValueSourceName("data_analysis_saved"))
|
||||
.withField(field("table_name", QFieldType.STRING))
|
||||
.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("bucket", QFieldType.STRING))
|
||||
.withField(field("severity", QFieldType.STRING))
|
||||
.withField(field("enabled", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("last_evaluated_at", QFieldType.DATE_TIME))
|
||||
.withField(field("state", QFieldType.STRING).withDefaultValue("OK").withPossibleValueSourceName("data_analysis_watch_state"))
|
||||
.withField(field("alert_key", QFieldType.STRING)));
|
||||
tables.add(base(config, RUN, "Analysis Runs", "history", "data_analysis.run")
|
||||
.withRecordLabelFormatAndFields("%s", "operation")
|
||||
.withField(field("table_name", QFieldType.STRING).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))
|
||||
.withRecordSecurityLock(ownerLock("user_id")));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
List<String> fields = new ArrayList<>(table.getFields().keySet());
|
||||
fields.removeAll(List.of("id", "createDate", "modifyDate"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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,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,167 @@
|
||||
/*
|
||||
* 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 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);
|
||||
}
|
||||
}
|
||||
@@ -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,167 @@
|
||||
/*
|
||||
* 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 DateProfile contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class DateProfile implements Serializable
|
||||
{
|
||||
private List<Metric> metrics = new java.util.ArrayList<>();
|
||||
private String unit;
|
||||
private List<ValueCount> buckets = List.of();
|
||||
private List<ValueCount> gaps = List.of();
|
||||
private List<ValueCount> dayOfWeek = List.of();
|
||||
private List<ValueCount> hourOfDay = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for metrics.
|
||||
*******************************************************************************/
|
||||
public List<Metric> getMetrics()
|
||||
{
|
||||
return (metrics);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for metrics.
|
||||
*******************************************************************************/
|
||||
public DateProfile withMetrics(List<Metric> metrics)
|
||||
{
|
||||
this.metrics = metrics;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for unit.
|
||||
*******************************************************************************/
|
||||
public String getUnit()
|
||||
{
|
||||
return (unit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for unit.
|
||||
*******************************************************************************/
|
||||
public DateProfile withUnit(String unit)
|
||||
{
|
||||
this.unit = unit;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for buckets.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getBuckets()
|
||||
{
|
||||
return (buckets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for buckets.
|
||||
*******************************************************************************/
|
||||
public DateProfile withBuckets(List<ValueCount> buckets)
|
||||
{
|
||||
this.buckets = buckets;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for gaps.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getGaps()
|
||||
{
|
||||
return (gaps);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for gaps.
|
||||
*******************************************************************************/
|
||||
public DateProfile withGaps(List<ValueCount> gaps)
|
||||
{
|
||||
this.gaps = gaps;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dayOfWeek.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getDayOfWeek()
|
||||
{
|
||||
return (dayOfWeek);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dayOfWeek.
|
||||
*******************************************************************************/
|
||||
public DateProfile withDayOfWeek(List<ValueCount> dayOfWeek)
|
||||
{
|
||||
this.dayOfWeek = dayOfWeek;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hourOfDay.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getHourOfDay()
|
||||
{
|
||||
return (hourOfDay);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hourOfDay.
|
||||
*******************************************************************************/
|
||||
public DateProfile withHourOfDay(List<ValueCount> hourOfDay)
|
||||
{
|
||||
this.hourOfDay = hourOfDay;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/*
|
||||
* 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 DistinctValuesPage contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class DistinctValuesPage 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 Integer page;
|
||||
private Integer pageSize;
|
||||
private Boolean hasMore;
|
||||
private Long totalDistinct;
|
||||
private List<ValueCount> values = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filter.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withFilter(QQueryFilter filter)
|
||||
{
|
||||
this.filter = filter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for estimate.
|
||||
*******************************************************************************/
|
||||
public Boolean getEstimate()
|
||||
{
|
||||
return (estimate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for estimate.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withEstimate(Boolean estimate)
|
||||
{
|
||||
this.estimate = estimate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public Long getSampleRows()
|
||||
{
|
||||
return (sampleRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withSampleRows(Long sampleRows)
|
||||
{
|
||||
this.sampleRows = sampleRows;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated.
|
||||
*******************************************************************************/
|
||||
public Boolean getTruncated()
|
||||
{
|
||||
return (truncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for truncated.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowsScanned.
|
||||
*******************************************************************************/
|
||||
public Long getRowsScanned()
|
||||
{
|
||||
return (rowsScanned);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowsScanned.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withRowsScanned(Long rowsScanned)
|
||||
{
|
||||
this.rowsScanned = rowsScanned;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for asOf.
|
||||
*******************************************************************************/
|
||||
public Instant getAsOf()
|
||||
{
|
||||
return (asOf);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for asOf.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withAsOf(Instant asOf)
|
||||
{
|
||||
this.asOf = asOf;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dataVersion.
|
||||
*******************************************************************************/
|
||||
public String getDataVersion()
|
||||
{
|
||||
return (dataVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dataVersion.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withDataVersion(String dataVersion)
|
||||
{
|
||||
this.dataVersion = dataVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for warnings.
|
||||
*******************************************************************************/
|
||||
public List<String> getWarnings()
|
||||
{
|
||||
return (warnings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for warnings.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withWarnings(List<String> warnings)
|
||||
{
|
||||
this.warnings = warnings;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldName.
|
||||
*******************************************************************************/
|
||||
public String getFieldName()
|
||||
{
|
||||
return (fieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldName.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withFieldName(String fieldName)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for page.
|
||||
*******************************************************************************/
|
||||
public Integer getPage()
|
||||
{
|
||||
return (page);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for page.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withPage(Integer page)
|
||||
{
|
||||
this.page = page;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pageSize.
|
||||
*******************************************************************************/
|
||||
public Integer getPageSize()
|
||||
{
|
||||
return (pageSize);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for pageSize.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withPageSize(Integer pageSize)
|
||||
{
|
||||
this.pageSize = pageSize;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hasMore.
|
||||
*******************************************************************************/
|
||||
public Boolean getHasMore()
|
||||
{
|
||||
return (hasMore);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hasMore.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withHasMore(Boolean hasMore)
|
||||
{
|
||||
this.hasMore = hasMore;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for totalDistinct.
|
||||
*******************************************************************************/
|
||||
public Long getTotalDistinct()
|
||||
{
|
||||
return (totalDistinct);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for totalDistinct.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withTotalDistinct(Long totalDistinct)
|
||||
{
|
||||
this.totalDistinct = totalDistinct;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for values.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getValues()
|
||||
{
|
||||
return (values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for values.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesPage withValues(List<ValueCount> values)
|
||||
{
|
||||
this.values = values;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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 DistinctValuesRequest contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class DistinctValuesRequest implements AnalysisRequest
|
||||
{
|
||||
private String tableName;
|
||||
private QQueryFilter filter;
|
||||
private Boolean exact = false;
|
||||
private String fieldName;
|
||||
private Integer page = 0;
|
||||
private Integer pageSize = 100;
|
||||
private String search;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesRequest withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filter.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesRequest withFilter(QQueryFilter filter)
|
||||
{
|
||||
this.filter = filter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for exact.
|
||||
*******************************************************************************/
|
||||
public Boolean getExact()
|
||||
{
|
||||
return (exact);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for exact.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesRequest withExact(Boolean exact)
|
||||
{
|
||||
this.exact = exact;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldName.
|
||||
*******************************************************************************/
|
||||
public String getFieldName()
|
||||
{
|
||||
return (fieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldName.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesRequest withFieldName(String fieldName)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for page.
|
||||
*******************************************************************************/
|
||||
public Integer getPage()
|
||||
{
|
||||
return (page);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for page.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesRequest withPage(Integer page)
|
||||
{
|
||||
this.page = page;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pageSize.
|
||||
*******************************************************************************/
|
||||
public Integer getPageSize()
|
||||
{
|
||||
return (pageSize);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for pageSize.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesRequest withPageSize(Integer pageSize)
|
||||
{
|
||||
this.pageSize = pageSize;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for search.
|
||||
*******************************************************************************/
|
||||
public String getSearch()
|
||||
{
|
||||
return (search);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for search.
|
||||
*******************************************************************************/
|
||||
public DistinctValuesRequest withSearch(String search)
|
||||
{
|
||||
this.search = search;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed Drill contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class Drill implements Serializable
|
||||
{
|
||||
private String tableName;
|
||||
private QQueryFilter filter;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public Drill withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (com.kingsrook.qbits.dataanalysis.engine.DrillFilters.restoreTypes(tableName, filter));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filter.
|
||||
*******************************************************************************/
|
||||
public Drill withFilter(QQueryFilter filter)
|
||||
{
|
||||
this.filter = filter;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed DuplicatesRequest contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class DuplicatesRequest implements AnalysisRequest
|
||||
{
|
||||
private String tableName;
|
||||
private QQueryFilter filter;
|
||||
private Boolean exact = false;
|
||||
private List<String> fields;
|
||||
private Integer limit = 50;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public DuplicatesRequest withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filter.
|
||||
*******************************************************************************/
|
||||
public DuplicatesRequest withFilter(QQueryFilter filter)
|
||||
{
|
||||
this.filter = filter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for exact.
|
||||
*******************************************************************************/
|
||||
public Boolean getExact()
|
||||
{
|
||||
return (exact);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for exact.
|
||||
*******************************************************************************/
|
||||
public DuplicatesRequest withExact(Boolean exact)
|
||||
{
|
||||
this.exact = exact;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fields.
|
||||
*******************************************************************************/
|
||||
public List<String> getFields()
|
||||
{
|
||||
return (fields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fields.
|
||||
*******************************************************************************/
|
||||
public DuplicatesRequest withFields(List<String> fields)
|
||||
{
|
||||
this.fields = fields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Maximum duplicate groups requested.
|
||||
*******************************************************************************/
|
||||
public Integer getLimit()
|
||||
{
|
||||
return (limit);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent duplicate group limit.
|
||||
*******************************************************************************/
|
||||
public DuplicatesRequest withLimit(Integer limit)
|
||||
{
|
||||
this.limit = limit;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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 DuplicatesResult contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class DuplicatesResult 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 List<String> fields;
|
||||
private List<ValueCount> groups = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filter.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withFilter(QQueryFilter filter)
|
||||
{
|
||||
this.filter = filter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for estimate.
|
||||
*******************************************************************************/
|
||||
public Boolean getEstimate()
|
||||
{
|
||||
return (estimate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for estimate.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withEstimate(Boolean estimate)
|
||||
{
|
||||
this.estimate = estimate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public Long getSampleRows()
|
||||
{
|
||||
return (sampleRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withSampleRows(Long sampleRows)
|
||||
{
|
||||
this.sampleRows = sampleRows;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated.
|
||||
*******************************************************************************/
|
||||
public Boolean getTruncated()
|
||||
{
|
||||
return (truncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for truncated.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowsScanned.
|
||||
*******************************************************************************/
|
||||
public Long getRowsScanned()
|
||||
{
|
||||
return (rowsScanned);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowsScanned.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withRowsScanned(Long rowsScanned)
|
||||
{
|
||||
this.rowsScanned = rowsScanned;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for asOf.
|
||||
*******************************************************************************/
|
||||
public Instant getAsOf()
|
||||
{
|
||||
return (asOf);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for asOf.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withAsOf(Instant asOf)
|
||||
{
|
||||
this.asOf = asOf;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dataVersion.
|
||||
*******************************************************************************/
|
||||
public String getDataVersion()
|
||||
{
|
||||
return (dataVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dataVersion.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withDataVersion(String dataVersion)
|
||||
{
|
||||
this.dataVersion = dataVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for warnings.
|
||||
*******************************************************************************/
|
||||
public List<String> getWarnings()
|
||||
{
|
||||
return (warnings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for warnings.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withWarnings(List<String> warnings)
|
||||
{
|
||||
this.warnings = warnings;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fields.
|
||||
*******************************************************************************/
|
||||
public List<String> getFields()
|
||||
{
|
||||
return (fields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fields.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withFields(List<String> fields)
|
||||
{
|
||||
this.fields = fields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for groups.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getGroups()
|
||||
{
|
||||
return (groups);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for groups.
|
||||
*******************************************************************************/
|
||||
public DuplicatesResult withGroups(List<ValueCount> groups)
|
||||
{
|
||||
this.groups = groups;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -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.result;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed FieldSummary contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class FieldSummary implements Serializable
|
||||
{
|
||||
private String field;
|
||||
private String label;
|
||||
private String type;
|
||||
private BigDecimal completeness;
|
||||
private Long distinct;
|
||||
private BigDecimal uniqueness;
|
||||
private Boolean constant;
|
||||
private Boolean empty;
|
||||
private Metric nulls;
|
||||
private Metric blanks;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for field.
|
||||
*******************************************************************************/
|
||||
public String getField()
|
||||
{
|
||||
return (field);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for field.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withField(String field)
|
||||
{
|
||||
this.field = field;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for type.
|
||||
*******************************************************************************/
|
||||
public String getType()
|
||||
{
|
||||
return (type);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for type.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withType(String type)
|
||||
{
|
||||
this.type = type;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for completeness.
|
||||
*******************************************************************************/
|
||||
public BigDecimal getCompleteness()
|
||||
{
|
||||
return (completeness);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for completeness.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withCompleteness(BigDecimal completeness)
|
||||
{
|
||||
this.completeness = completeness;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for distinct.
|
||||
*******************************************************************************/
|
||||
public Long getDistinct()
|
||||
{
|
||||
return (distinct);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for distinct.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withDistinct(Long distinct)
|
||||
{
|
||||
this.distinct = distinct;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uniqueness.
|
||||
*******************************************************************************/
|
||||
public BigDecimal getUniqueness()
|
||||
{
|
||||
return (uniqueness);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uniqueness.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withUniqueness(BigDecimal uniqueness)
|
||||
{
|
||||
this.uniqueness = uniqueness;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for constant.
|
||||
*******************************************************************************/
|
||||
public Boolean getConstant()
|
||||
{
|
||||
return (constant);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for constant.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withConstant(Boolean constant)
|
||||
{
|
||||
this.constant = constant;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for empty.
|
||||
*******************************************************************************/
|
||||
public Boolean getEmpty()
|
||||
{
|
||||
return (empty);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for empty.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withEmpty(Boolean empty)
|
||||
{
|
||||
this.empty = empty;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for nulls.
|
||||
*******************************************************************************/
|
||||
public Metric getNulls()
|
||||
{
|
||||
return (nulls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for nulls.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withNulls(Metric nulls)
|
||||
{
|
||||
this.nulls = nulls;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for blanks.
|
||||
*******************************************************************************/
|
||||
public Metric getBlanks()
|
||||
{
|
||||
return (blanks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for blanks.
|
||||
*******************************************************************************/
|
||||
public FieldSummary withBlanks(Metric blanks)
|
||||
{
|
||||
this.blanks = blanks;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 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 Metric contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class Metric implements Serializable
|
||||
{
|
||||
private Boolean estimate = false;
|
||||
private String key;
|
||||
private String label;
|
||||
private Serializable value;
|
||||
private ValueType valueType;
|
||||
private String formatted;
|
||||
private Drill drill;
|
||||
private Boolean suppressed = false;
|
||||
private String suppressedReason;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the metric describes a sampled estimate.
|
||||
*******************************************************************************/
|
||||
public Boolean getEstimate()
|
||||
{
|
||||
return (estimate);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark an estimated metric independently of the exact population row total.
|
||||
*******************************************************************************/
|
||||
public Metric withEstimate(Boolean estimate)
|
||||
{
|
||||
this.estimate = estimate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for key.
|
||||
*******************************************************************************/
|
||||
public String getKey()
|
||||
{
|
||||
return (key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for key.
|
||||
*******************************************************************************/
|
||||
public Metric withKey(String key)
|
||||
{
|
||||
this.key = key;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label.
|
||||
*******************************************************************************/
|
||||
public Metric withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for value.
|
||||
*******************************************************************************/
|
||||
public Serializable getValue()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for value.
|
||||
*******************************************************************************/
|
||||
public Metric withValue(Serializable value)
|
||||
{
|
||||
this.value = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for valueType.
|
||||
*******************************************************************************/
|
||||
public ValueType getValueType()
|
||||
{
|
||||
return (valueType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for valueType.
|
||||
*******************************************************************************/
|
||||
public Metric withValueType(ValueType valueType)
|
||||
{
|
||||
this.valueType = valueType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for formatted.
|
||||
*******************************************************************************/
|
||||
public String getFormatted()
|
||||
{
|
||||
return (formatted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for formatted.
|
||||
*******************************************************************************/
|
||||
public Metric withFormatted(String formatted)
|
||||
{
|
||||
this.formatted = formatted;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for drill.
|
||||
*******************************************************************************/
|
||||
public Drill getDrill()
|
||||
{
|
||||
return (drill);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for drill.
|
||||
*******************************************************************************/
|
||||
public Metric withDrill(Drill drill)
|
||||
{
|
||||
this.drill = drill;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for suppressed.
|
||||
*******************************************************************************/
|
||||
public Boolean getSuppressed()
|
||||
{
|
||||
return (suppressed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for suppressed.
|
||||
*******************************************************************************/
|
||||
public Metric withSuppressed(Boolean suppressed)
|
||||
{
|
||||
this.suppressed = suppressed;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for suppressedReason.
|
||||
*******************************************************************************/
|
||||
public String getSuppressedReason()
|
||||
{
|
||||
return (suppressedReason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for suppressedReason.
|
||||
*******************************************************************************/
|
||||
public Metric withSuppressedReason(String suppressedReason)
|
||||
{
|
||||
this.suppressedReason = suppressedReason;
|
||||
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 NumericProfile contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class NumericProfile implements Serializable
|
||||
{
|
||||
private List<Metric> metrics = new java.util.ArrayList<>();
|
||||
private List<ValueCount> histogram = List.of();
|
||||
private List<ValueCount> outliers = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for metrics.
|
||||
*******************************************************************************/
|
||||
public List<Metric> getMetrics()
|
||||
{
|
||||
return (metrics);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for metrics.
|
||||
*******************************************************************************/
|
||||
public NumericProfile withMetrics(List<Metric> metrics)
|
||||
{
|
||||
this.metrics = metrics;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for histogram.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getHistogram()
|
||||
{
|
||||
return (histogram);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for histogram.
|
||||
*******************************************************************************/
|
||||
public NumericProfile withHistogram(List<ValueCount> histogram)
|
||||
{
|
||||
this.histogram = histogram;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outliers.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getOutliers()
|
||||
{
|
||||
return (outliers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outliers.
|
||||
*******************************************************************************/
|
||||
public NumericProfile withOutliers(List<ValueCount> outliers)
|
||||
{
|
||||
this.outliers = outliers;
|
||||
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.math.BigDecimal;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed Point contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class Point implements Serializable
|
||||
{
|
||||
private Serializable x;
|
||||
private BigDecimal y;
|
||||
private String label;
|
||||
private Drill drill;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for x.
|
||||
*******************************************************************************/
|
||||
public Serializable getX()
|
||||
{
|
||||
return (x);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for x.
|
||||
*******************************************************************************/
|
||||
public Point withX(Serializable x)
|
||||
{
|
||||
this.x = x;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for y.
|
||||
*******************************************************************************/
|
||||
public BigDecimal getY()
|
||||
{
|
||||
return (y);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for y.
|
||||
*******************************************************************************/
|
||||
public Point withY(BigDecimal y)
|
||||
{
|
||||
this.y = y;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label.
|
||||
*******************************************************************************/
|
||||
public Point withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for drill.
|
||||
*******************************************************************************/
|
||||
public Drill getDrill()
|
||||
{
|
||||
return (drill);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for drill.
|
||||
*******************************************************************************/
|
||||
public Point withDrill(Drill drill)
|
||||
{
|
||||
this.drill = drill;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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 com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed RunStatus contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class RunStatus implements Serializable
|
||||
{
|
||||
private String runId;
|
||||
private String status;
|
||||
private String error;
|
||||
private ColumnProfile columnProfile;
|
||||
private TableProfile tableProfile;
|
||||
private Instant startedAt;
|
||||
private Instant finishedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runId.
|
||||
*******************************************************************************/
|
||||
public String getRunId()
|
||||
{
|
||||
return (runId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for runId.
|
||||
*******************************************************************************/
|
||||
public RunStatus withRunId(String runId)
|
||||
{
|
||||
this.runId = runId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status.
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status.
|
||||
*******************************************************************************/
|
||||
public RunStatus withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for error.
|
||||
*******************************************************************************/
|
||||
public String getError()
|
||||
{
|
||||
return (error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for error.
|
||||
*******************************************************************************/
|
||||
public RunStatus withError(String error)
|
||||
{
|
||||
this.error = error;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for columnProfile.
|
||||
*******************************************************************************/
|
||||
public ColumnProfile getColumnProfile()
|
||||
{
|
||||
return (columnProfile);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for columnProfile.
|
||||
*******************************************************************************/
|
||||
public RunStatus withColumnProfile(ColumnProfile columnProfile)
|
||||
{
|
||||
this.columnProfile = columnProfile;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableProfile.
|
||||
*******************************************************************************/
|
||||
public TableProfile getTableProfile()
|
||||
{
|
||||
return (tableProfile);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableProfile.
|
||||
*******************************************************************************/
|
||||
public RunStatus withTableProfile(TableProfile tableProfile)
|
||||
{
|
||||
this.tableProfile = tableProfile;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getStartedAt()
|
||||
{
|
||||
return (startedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for startedAt.
|
||||
*******************************************************************************/
|
||||
public RunStatus withStartedAt(Instant startedAt)
|
||||
{
|
||||
this.startedAt = startedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for finishedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getFinishedAt()
|
||||
{
|
||||
return (finishedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for finishedAt.
|
||||
*******************************************************************************/
|
||||
public RunStatus withFinishedAt(Instant finishedAt)
|
||||
{
|
||||
this.finishedAt = finishedAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 Series contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class Series implements Serializable
|
||||
{
|
||||
private String name;
|
||||
private List<Point> points = new java.util.ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name.
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name.
|
||||
*******************************************************************************/
|
||||
public Series withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for points.
|
||||
*******************************************************************************/
|
||||
public List<Point> getPoints()
|
||||
{
|
||||
return (points);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for points.
|
||||
*******************************************************************************/
|
||||
public Series withPoints(List<Point> points)
|
||||
{
|
||||
this.points = points;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* 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 TableProfile contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class TableProfile 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 Metric rowCount;
|
||||
private List<FieldSummary> fields = new java.util.ArrayList<>();
|
||||
private Chart heatmap;
|
||||
private List<CandidateKey> candidateKeys = new java.util.ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public TableProfile withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filter.
|
||||
*******************************************************************************/
|
||||
public TableProfile withFilter(QQueryFilter filter)
|
||||
{
|
||||
this.filter = filter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for estimate.
|
||||
*******************************************************************************/
|
||||
public Boolean getEstimate()
|
||||
{
|
||||
return (estimate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for estimate.
|
||||
*******************************************************************************/
|
||||
public TableProfile withEstimate(Boolean estimate)
|
||||
{
|
||||
this.estimate = estimate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public Long getSampleRows()
|
||||
{
|
||||
return (sampleRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public TableProfile withSampleRows(Long sampleRows)
|
||||
{
|
||||
this.sampleRows = sampleRows;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated.
|
||||
*******************************************************************************/
|
||||
public Boolean getTruncated()
|
||||
{
|
||||
return (truncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for truncated.
|
||||
*******************************************************************************/
|
||||
public TableProfile withTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowsScanned.
|
||||
*******************************************************************************/
|
||||
public Long getRowsScanned()
|
||||
{
|
||||
return (rowsScanned);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowsScanned.
|
||||
*******************************************************************************/
|
||||
public TableProfile withRowsScanned(Long rowsScanned)
|
||||
{
|
||||
this.rowsScanned = rowsScanned;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for asOf.
|
||||
*******************************************************************************/
|
||||
public Instant getAsOf()
|
||||
{
|
||||
return (asOf);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for asOf.
|
||||
*******************************************************************************/
|
||||
public TableProfile withAsOf(Instant asOf)
|
||||
{
|
||||
this.asOf = asOf;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dataVersion.
|
||||
*******************************************************************************/
|
||||
public String getDataVersion()
|
||||
{
|
||||
return (dataVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dataVersion.
|
||||
*******************************************************************************/
|
||||
public TableProfile withDataVersion(String dataVersion)
|
||||
{
|
||||
this.dataVersion = dataVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for warnings.
|
||||
*******************************************************************************/
|
||||
public List<String> getWarnings()
|
||||
{
|
||||
return (warnings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for warnings.
|
||||
*******************************************************************************/
|
||||
public TableProfile withWarnings(List<String> warnings)
|
||||
{
|
||||
this.warnings = warnings;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowCount.
|
||||
*******************************************************************************/
|
||||
public Metric getRowCount()
|
||||
{
|
||||
return (rowCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowCount.
|
||||
*******************************************************************************/
|
||||
public TableProfile withRowCount(Metric rowCount)
|
||||
{
|
||||
this.rowCount = rowCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fields.
|
||||
*******************************************************************************/
|
||||
public List<FieldSummary> getFields()
|
||||
{
|
||||
return (fields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fields.
|
||||
*******************************************************************************/
|
||||
public TableProfile withFields(List<FieldSummary> fields)
|
||||
{
|
||||
this.fields = fields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for heatmap.
|
||||
*******************************************************************************/
|
||||
public Chart getHeatmap()
|
||||
{
|
||||
return (heatmap);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for heatmap.
|
||||
*******************************************************************************/
|
||||
public TableProfile withHeatmap(Chart heatmap)
|
||||
{
|
||||
this.heatmap = heatmap;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for candidateKeys.
|
||||
*******************************************************************************/
|
||||
public List<CandidateKey> getCandidateKeys()
|
||||
{
|
||||
return (candidateKeys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for candidateKeys.
|
||||
*******************************************************************************/
|
||||
public TableProfile withCandidateKeys(List<CandidateKey> candidateKeys)
|
||||
{
|
||||
this.candidateKeys = candidateKeys;
|
||||
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.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed TableProfileRequest contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class TableProfileRequest implements AnalysisRequest
|
||||
{
|
||||
private String tableName;
|
||||
private QQueryFilter filter;
|
||||
private Boolean exact = false;
|
||||
private List<String> fields;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public TableProfileRequest withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter.
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filter.
|
||||
*******************************************************************************/
|
||||
public TableProfileRequest withFilter(QQueryFilter filter)
|
||||
{
|
||||
this.filter = filter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for exact.
|
||||
*******************************************************************************/
|
||||
public Boolean getExact()
|
||||
{
|
||||
return (exact);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for exact.
|
||||
*******************************************************************************/
|
||||
public TableProfileRequest withExact(Boolean exact)
|
||||
{
|
||||
this.exact = exact;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fields.
|
||||
*******************************************************************************/
|
||||
public List<String> getFields()
|
||||
{
|
||||
return (fields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fields.
|
||||
*******************************************************************************/
|
||||
public TableProfileRequest withFields(List<String> fields)
|
||||
{
|
||||
this.fields = fields;
|
||||
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 TextProfile contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class TextProfile implements Serializable
|
||||
{
|
||||
private List<Metric> metrics = new java.util.ArrayList<>();
|
||||
private List<ValueCount> histogram = List.of();
|
||||
private List<ValueCount> patterns = List.of();
|
||||
private Long sampleRows;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for metrics.
|
||||
*******************************************************************************/
|
||||
public List<Metric> getMetrics()
|
||||
{
|
||||
return (metrics);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for metrics.
|
||||
*******************************************************************************/
|
||||
public TextProfile withMetrics(List<Metric> metrics)
|
||||
{
|
||||
this.metrics = metrics;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for histogram.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getHistogram()
|
||||
{
|
||||
return (histogram);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for histogram.
|
||||
*******************************************************************************/
|
||||
public TextProfile withHistogram(List<ValueCount> histogram)
|
||||
{
|
||||
this.histogram = histogram;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for patterns.
|
||||
*******************************************************************************/
|
||||
public List<ValueCount> getPatterns()
|
||||
{
|
||||
return (patterns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for patterns.
|
||||
*******************************************************************************/
|
||||
public TextProfile withPatterns(List<ValueCount> patterns)
|
||||
{
|
||||
this.patterns = patterns;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public Long getSampleRows()
|
||||
{
|
||||
return (sampleRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sampleRows.
|
||||
*******************************************************************************/
|
||||
public TextProfile withSampleRows(Long sampleRows)
|
||||
{
|
||||
this.sampleRows = sampleRows;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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.math.BigDecimal;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed ValueCount contract; independent of any presentation framework.
|
||||
*******************************************************************************/
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
public class ValueCount implements Serializable
|
||||
{
|
||||
private Serializable value;
|
||||
private String label;
|
||||
private Long count;
|
||||
private BigDecimal share;
|
||||
private Drill drill;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for value.
|
||||
*******************************************************************************/
|
||||
public Serializable getValue()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for value.
|
||||
*******************************************************************************/
|
||||
public ValueCount withValue(Serializable value)
|
||||
{
|
||||
this.value = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label.
|
||||
*******************************************************************************/
|
||||
public ValueCount withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for count.
|
||||
*******************************************************************************/
|
||||
public Long getCount()
|
||||
{
|
||||
return (count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for count.
|
||||
*******************************************************************************/
|
||||
public ValueCount withCount(Long count)
|
||||
{
|
||||
this.count = count;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for share.
|
||||
*******************************************************************************/
|
||||
public BigDecimal getShare()
|
||||
{
|
||||
return (share);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for share.
|
||||
*******************************************************************************/
|
||||
public ValueCount withShare(BigDecimal share)
|
||||
{
|
||||
this.share = share;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for drill.
|
||||
*******************************************************************************/
|
||||
public Drill getDrill()
|
||||
{
|
||||
return (drill);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for drill.
|
||||
*******************************************************************************/
|
||||
public ValueCount withDrill(Drill drill)
|
||||
{
|
||||
this.drill = drill;
|
||||
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 ValueType values.
|
||||
*******************************************************************************/
|
||||
public enum ValueType
|
||||
{
|
||||
COUNT, DECIMAL, PERCENT, DATE, DATE_TIME, DURATION, TEXT
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisService;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.EffectiveConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.result.AnalysisRequest;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
|
||||
import com.kingsrook.qbits.dataanalysis.result.RunStatus;
|
||||
import com.kingsrook.qbits.dataanalysis.result.TableProfile;
|
||||
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
/*******************************************************************************
|
||||
** Persistent owner-scoped runs. Results are guarded before being serialized.
|
||||
** Cache identity includes session policy, effective config and data version.
|
||||
*******************************************************************************/
|
||||
public class AnalysisRunService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AnalysisRunService.class);
|
||||
private final DataAnalysisService service;
|
||||
private final Executor executor;
|
||||
|
||||
/*******************************************************************************
|
||||
** Use a host-managed executor without inheriting a caller transaction.
|
||||
*******************************************************************************/
|
||||
public AnalysisRunService(DataAnalysisService service, Executor executor)
|
||||
{
|
||||
this.service = service;
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Estimate is transient; exact results are persisted and reused.
|
||||
*******************************************************************************/
|
||||
public ColumnProfile columnProfile(ColumnProfileRequest request) throws QException
|
||||
{
|
||||
service.validate(request);
|
||||
AnalysisBackend backend = service.backend(request.getTableName());
|
||||
AnalysisBackend.Scope scope = backend.scope(request.getTableName(), request.getFilter(), Boolean.TRUE.equals(request.getExact()));
|
||||
if(scope.estimate())
|
||||
{
|
||||
return (service.compute(request, backend, scope).withDataVersion(DataVersion.of(service.backend(request.getTableName()), request.getTableName(), request.getFilter())));
|
||||
}
|
||||
return (exact(request).getColumnProfile());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table profiles share the same lifecycle and cache isolation.
|
||||
*******************************************************************************/
|
||||
public TableProfile tableProfile(TableProfileRequest request) throws QException
|
||||
{
|
||||
service.validate(request);
|
||||
AnalysisBackend backend = service.backend(request.getTableName());
|
||||
AnalysisBackend.Scope scope = backend.scope(request.getTableName(), request.getFilter(), Boolean.TRUE.equals(request.getExact()));
|
||||
if(scope.estimate())
|
||||
{
|
||||
return (service.compute(request, backend, scope).withDataVersion(DataVersion.of(service.backend(request.getTableName()), request.getTableName(), request.getFilter())));
|
||||
}
|
||||
return (exact(request).getTableProfile());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete synchronously for already-small populations and explicit exact calls.
|
||||
*******************************************************************************/
|
||||
private RunStatus exact(AnalysisRequest request) throws QException
|
||||
{
|
||||
AnalysisRequest snapshot = snapshot(request);
|
||||
QRecord cached = cached(snapshot);
|
||||
if(cached != null)
|
||||
{
|
||||
return (decode(cached));
|
||||
}
|
||||
QRecord run = insert(snapshot);
|
||||
execute(snapshot, run);
|
||||
RunStatus result = getRun(run.getValueString("id"));
|
||||
if(!"COMPLETE".equals(result.getStatus()))
|
||||
{
|
||||
throw new QException("Analysis failed; inspect the run status and retry.");
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot both request and session before dispatching work. No ambient
|
||||
** transaction or privileged action stack is copied into the worker.
|
||||
*******************************************************************************/
|
||||
public String startExactRun(AnalysisRequest request) throws QException
|
||||
{
|
||||
service.validate(request);
|
||||
AnalysisRequest snapshot = snapshot(request);
|
||||
service.validate(snapshot);
|
||||
QRecord cached = cached(snapshot);
|
||||
if(cached != null)
|
||||
{
|
||||
return (cached.getValueString("id"));
|
||||
}
|
||||
QRecord run = insert(snapshot);
|
||||
CapturedContext context;
|
||||
try
|
||||
{
|
||||
context = new CapturedContext(QContext.getQInstance(), QContext.getQSession().clone());
|
||||
}
|
||||
catch(CloneNotSupportedException e)
|
||||
{
|
||||
fail(run);
|
||||
throw new QException("Could not capture the analysis session.", e);
|
||||
}
|
||||
try
|
||||
{
|
||||
executor.execute(() -> QContext.withTemporaryContext(context, () -> execute(snapshot, run)));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
fail(run);
|
||||
throw new QException("The analysis executor could not accept this run.", e);
|
||||
}
|
||||
return (run.getValueString("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only completed, fresh, owner-and-policy-matching rows qualify as cache hits.
|
||||
*******************************************************************************/
|
||||
private QRecord cached(AnalysisRequest request) throws QException
|
||||
{
|
||||
service.validate(request);
|
||||
EffectiveConfig config = service.getConfigResolver().resolve(request.getTableName());
|
||||
if(config.getCacheTtlMinutes() == 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QQueryFilter filter = DrillFilters.condition("user_id", QCriteriaOperator.EQUALS, userId())
|
||||
.withCriteria("request_hash", QCriteriaOperator.EQUALS, hash(request))
|
||||
.withCriteria("data_version", QCriteriaOperator.EQUALS, DataVersion.of(service.backend(request.getTableName()), request.getTableName(), request.getFilter()))
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "COMPLETE")
|
||||
.withCriteria("finished_at", QCriteriaOperator.GREATER_THAN, Instant.now().minus(config.getCacheTtlMinutes(), ChronoUnit.MINUTES))
|
||||
.withCriteria("finished_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now())
|
||||
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(1);
|
||||
List<QRecord> rows = query(filter);
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run inserts and updates are ordinary user actions under owner locks.
|
||||
*******************************************************************************/
|
||||
private QRecord insert(AnalysisRequest request) throws QException
|
||||
{
|
||||
QRecord run = new QRecord().withValue("table_name", request.getTableName()).withValue("operation", operation(request))
|
||||
.withValue("request_hash", hash(request)).withValue("filter_hash", digest(JsonUtils.toJson(DrillFilters.copy(request.getFilter()))))
|
||||
.withValue("data_version", DataVersion.of(service.backend(request.getTableName()), request.getTableName(), request.getFilter()))
|
||||
.withValue("status", "RUNNING").withValue("started_at", Instant.now()).withValue("user_id", userId());
|
||||
InsertInput input = new InsertInput(DataAnalysisTables.RUN).withRecords(List.of(run));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
QRecord saved = new InsertAction().execute(input).getRecords().getFirst();
|
||||
check(saved);
|
||||
return (saved);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failure text is intentionally generic; backend errors may contain user data.
|
||||
*******************************************************************************/
|
||||
private void execute(AnalysisRequest request, QRecord run)
|
||||
{
|
||||
try
|
||||
{
|
||||
service.validate(request);
|
||||
AnalysisBackend backend = service.backend(request.getTableName());
|
||||
AnalysisBackend.Scope scope = backend.scope(request.getTableName(), request.getFilter(), true);
|
||||
Serializable result;
|
||||
if(request instanceof ColumnProfileRequest column)
|
||||
{
|
||||
result = service.compute(column, backend, scope).withDataVersion(run.getValueString("data_version"));
|
||||
}
|
||||
else
|
||||
{
|
||||
result = service.compute((TableProfileRequest) request, backend, scope).withDataVersion(run.getValueString("data_version"));
|
||||
}
|
||||
if(!hash(request).equals(run.getValueString("request_hash")))
|
||||
{
|
||||
throw new QException("Analysis policy changed during the run.");
|
||||
}
|
||||
update(new QRecord().withValue("id", run.getValue("id")).withValue("status", "COMPLETE").withValue("finished_at", Instant.now())
|
||||
.withValue("rows_scanned", scope.rowsScanned()).withValue("sampled", false).withValue("result_json", JsonUtils.toJson(result)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Analysis execution failed; recording a safe failure status.", e);
|
||||
fail(run);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist safe failure status; persistence failures are logged separately.
|
||||
*******************************************************************************/
|
||||
private void fail(QRecord run)
|
||||
{
|
||||
try
|
||||
{
|
||||
update(new QRecord().withValue("id", run.getValue("id")).withValue("status", "FAILED").withValue("finished_at", Instant.now())
|
||||
.withValue("result_json", null).withValue("error", "Analysis could not be completed. Check permissions and settings, then retry."));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
LOG.warn("Could not persist analysis failure status.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck source-table access and policy before releasing historical data.
|
||||
*******************************************************************************/
|
||||
public RunStatus getRun(String id) throws QException
|
||||
{
|
||||
Integer key;
|
||||
try
|
||||
{
|
||||
key = Integer.valueOf(id);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
throw new QException("Invalid analysis run id.");
|
||||
}
|
||||
List<QRecord> rows = query(DrillFilters.condition("id", QCriteriaOperator.EQUALS, key).withCriteria("user_id", QCriteriaOperator.EQUALS, userId()).withLimit(1));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw new QPermissionDeniedException("Analysis run is unavailable.");
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
service.capabilities(row.getValueString("table_name"));
|
||||
RunStatus status = decode(row);
|
||||
if("COMPLETE".equals(status.getStatus()))
|
||||
{
|
||||
AnalysisRequest request = status.getColumnProfile() != null
|
||||
? new ColumnProfileRequest().withTableName(status.getColumnProfile().getTableName()).withFieldName(status.getColumnProfile().getFieldName()).withFilter(status.getColumnProfile().getFilter()).withExact(true)
|
||||
: new TableProfileRequest().withTableName(status.getTableProfile().getTableName()).withFields(status.getTableProfile().getFields().stream().map(f -> f.getField()).toList()).withFilter(status.getTableProfile().getFilter()).withExact(true);
|
||||
service.validate(request);
|
||||
if(!hash(request).equals(row.getValueString("request_hash")))
|
||||
{
|
||||
throw new QPermissionDeniedException("Analysis policy changed; request a new run.");
|
||||
}
|
||||
}
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deserialize typed payloads through core JsonUtils.
|
||||
*******************************************************************************/
|
||||
private RunStatus decode(QRecord row) throws QException
|
||||
{
|
||||
RunStatus status = new RunStatus().withRunId(row.getValueString("id")).withStatus(row.getValueString("status")).withError(row.getValueString("error"))
|
||||
.withStartedAt(row.getValueInstant("started_at")).withFinishedAt(row.getValueInstant("finished_at"));
|
||||
try
|
||||
{
|
||||
if("COMPLETE".equals(status.getStatus()))
|
||||
{
|
||||
if("COLUMN_PROFILE".equals(row.getValueString("operation")))
|
||||
{
|
||||
status.withColumnProfile(JsonUtils.toObject(row.getValueString("result_json"), ColumnProfile.class));
|
||||
}
|
||||
else
|
||||
{
|
||||
status.withTableProfile(JsonUtils.toObject(row.getValueString("result_json"), TableProfile.class));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw new QException("Stored analysis result could not be read.", e);
|
||||
}
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read only through enforced core actions, including heavy result JSON.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(DataAnalysisTables.RUN).withFilter(filter).withShouldFetchHeavyFields(true);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write only supplied run fields and check core record-level failures.
|
||||
*******************************************************************************/
|
||||
private static void update(QRecord record) throws QException
|
||||
{
|
||||
UpdateInput input = new UpdateInput(DataAnalysisTables.RUN).withRecords(List.of(record));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
check(new UpdateAction().execute(input).getRecords().getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core can return record-level errors without throwing.
|
||||
*******************************************************************************/
|
||||
private static void check(QRecord record) throws QException
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw new QException("Analysis run could not be stored.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An authenticated identity is mandatory even on unprotected host tables.
|
||||
*******************************************************************************/
|
||||
private static String userId() throws QException
|
||||
{
|
||||
if(QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getIdReference() == null)
|
||||
{
|
||||
throw new QPermissionDeniedException("Sign in to run an analysis.");
|
||||
}
|
||||
return (QContext.getQSession().getUser().getIdReference());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact requests have canonical selected fields and no pagination metadata.
|
||||
*******************************************************************************/
|
||||
private AnalysisRequest snapshot(AnalysisRequest request) throws QException
|
||||
{
|
||||
if(request instanceof ColumnProfileRequest column)
|
||||
{
|
||||
return (new ColumnProfileRequest().withTableName(column.getTableName()).withFieldName(column.getFieldName()).withFilter(DrillFilters.restoreTypes(column.getTableName(), column.getFilter())).withExact(true));
|
||||
}
|
||||
if(request instanceof TableProfileRequest table)
|
||||
{
|
||||
List<String> fields = service.capabilities(table.getTableName()).getFields().stream()
|
||||
.filter(f -> table.getFields() == null || table.getFields().contains(f)).sorted().toList();
|
||||
return (new TableProfileRequest().withTableName(table.getTableName()).withFields(fields).withFilter(DrillFilters.restoreTypes(table.getTableName(), table.getFilter())).withExact(true));
|
||||
}
|
||||
throw new QException("Exact runs support column and table profiles only.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable operation identity.
|
||||
*******************************************************************************/
|
||||
private static String operation(AnalysisRequest request)
|
||||
{
|
||||
return (request instanceof ColumnProfileRequest ? "COLUMN_PROFILE" : "TABLE_PROFILE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize JSON objects and commutative filter lists, then SHA-256. Policy
|
||||
** values are hashed, never persisted as plaintext in the cache identity.
|
||||
*******************************************************************************/
|
||||
private String hash(AnalysisRequest request) throws QException
|
||||
{
|
||||
Map<String, Object> identity = new LinkedHashMap<>();
|
||||
identity.put("request", snapshot(request));
|
||||
identity.put("operation", operation(request));
|
||||
identity.put("user", userId());
|
||||
identity.put("permissions", QContext.getQSession().getPermissions().stream().sorted().toList());
|
||||
identity.put("keys", QContext.getQSession().getSecurityKeyValues());
|
||||
identity.put("zone", ValueUtils.getSessionOrInstanceZoneId().getId());
|
||||
identity.put("config", service.getConfigResolver().resolve(request.getTableName()));
|
||||
identity.put("fields", service.capabilities(request.getTableName()).getFields().stream().sorted().toList());
|
||||
identity.put("policy", com.kingsrook.qbits.dataanalysis.engine.AnalysisLabels.policy(request.getTableName()));
|
||||
identity.put("backendVariants", QContext.getQSession().getBackendVariants());
|
||||
try
|
||||
{
|
||||
JsonNode json = new ObjectMapper().readTree(JsonUtils.toJson(identity));
|
||||
return (digest(JsonUtils.toJson(normalize(json, ""))));
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw new QException("Could not normalize analysis request.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve ordered parameter arrays, but sort commutative filter clauses.
|
||||
*******************************************************************************/
|
||||
private static Object normalize(JsonNode node, String key)
|
||||
{
|
||||
if(node.isObject())
|
||||
{
|
||||
Map<String, Object> result = new TreeMap<>();
|
||||
node.fields().forEachRemaining(e -> result.put(e.getKey(), normalize(e.getValue(), e.getKey())));
|
||||
return (result);
|
||||
}
|
||||
if(node.isArray())
|
||||
{
|
||||
List<Object> result = new ArrayList<>();
|
||||
node.forEach(value -> result.add(normalize(value, key)));
|
||||
if(List.of("criteria", "subFilters", "analyzableFields", "excludedFields").contains(key))
|
||||
{
|
||||
result.sort(Comparator.comparing(JsonUtils::toJson));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
return (node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 is guaranteed by the Java platform.
|
||||
*******************************************************************************/
|
||||
private static String digest(String value) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(java.security.NoSuchAlgorithmException e)
|
||||
{
|
||||
throw new QException("SHA-256 unavailable.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisBackend;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.FieldSecurityLockHelper;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Cheap filtered data version; deliberately not a transactional snapshot guarantee.
|
||||
*******************************************************************************/
|
||||
public final class DataVersion
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DataVersion()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never bypass a lock on a modification field to construct a cache key.
|
||||
*******************************************************************************/
|
||||
public static String of(AnalysisBackend backend, String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(table);
|
||||
String versionField = metadata.getFields().values().stream().filter(FieldSecurityLockHelper::isFieldAccessible)
|
||||
.filter(f -> isModificationField(f.getName())).map(QFieldMetaData::getName).findFirst().orElse(metadata.getPrimaryKeyField());
|
||||
Serializable maximum = backend.extrema(table, filter, versionField).get("max");
|
||||
return (backend.count(table, filter) + ":" + versionField + ":" + maximum);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Conventional host modification timestamps, in camel or snake case.
|
||||
*******************************************************************************/
|
||||
private static boolean isModificationField(String field)
|
||||
{
|
||||
String name = field.replace("_", "").toLowerCase(Locale.ROOT);
|
||||
return (name.equals("modifydate") || name.equals("modifiedat") || name.equals("updatedat") || name.equals("modifieddate"));
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Configuration for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitConfig implements QBitConfig
|
||||
{
|
||||
private String targetTableName;
|
||||
private Boolean enableFeatureX = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate configuration before QBit is produced.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
if(targetTableName != null && qInstance.getTable(targetTableName) == null)
|
||||
{
|
||||
errors.add("Target table not found: " + targetTableName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetTableName
|
||||
*******************************************************************************/
|
||||
public String getTargetTableName()
|
||||
{
|
||||
return targetTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetTableName
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withTargetTableName(String targetTableName)
|
||||
{
|
||||
this.targetTableName = targetTableName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public Boolean getEnableFeatureX()
|
||||
{
|
||||
return enableFeatureX;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX)
|
||||
{
|
||||
this.enableFeatureX = enableFeatureX;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Producer for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitProducer implements QBitProducer
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-example-extension";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private ExampleExtensionQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce this QBit into the given QInstance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void produce(QInstance qInstance, String namespace) throws QException
|
||||
{
|
||||
QBitMetaData qBitMetaData = new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(namespace)
|
||||
.withConfig(config);
|
||||
|
||||
qInstance.addQBit(qBitMetaData);
|
||||
|
||||
if(config != null && config.getTargetTableName() != null)
|
||||
{
|
||||
applyTableCustomizations(qInstance, config.getTargetTableName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the target table.
|
||||
*******************************************************************************/
|
||||
private void applyTableCustomizations(QInstance qInstance, String tableName)
|
||||
{
|
||||
// Implement your extension logic here
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for config
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Example table customizer.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.customizers;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Example table customizer that adds behavior to existing tables.
|
||||
*******************************************************************************/
|
||||
public class ExampleTableCustomizer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the given table.
|
||||
*******************************************************************************/
|
||||
public void customize(QTableMetaData table)
|
||||
{
|
||||
// Example: Add audit tracking fields
|
||||
// table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisFormatting;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.MetricLabels;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.NiceBins;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.ProfileSupport;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ValueType;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Public presentation contracts, including precision and calendar edge cases.
|
||||
*******************************************************************************/
|
||||
class AnalysisFormattingTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Raw ratios remain numeric while display strings are ready for a CLI.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void numbersPercentagesAndLengths()
|
||||
{
|
||||
assertThat(ProfileSupport.metric("rows", 12498L, ValueType.COUNT).getFormatted()).isEqualTo("12,498");
|
||||
for(String key : List.of("uniqueness", "completeness", "share"))
|
||||
{
|
||||
var metric = ProfileSupport.metric(key, new BigDecimal("0.987654321"), ValueType.PERCENT);
|
||||
assertThat(metric.getValue()).isEqualTo(new BigDecimal("0.987654"));
|
||||
assertThat(metric.getFormatted()).isEqualTo("98.8%");
|
||||
assertThat(metric.getValueType()).isEqualTo(ValueType.PERCENT);
|
||||
}
|
||||
assertThat(ProfileSupport.metric("share", BigDecimal.ONE, ValueType.PERCENT).getFormatted()).isEqualTo("100%");
|
||||
assertThat(ProfileSupport.metric("share", BigDecimal.ZERO, ValueType.PERCENT).getFormatted()).isEqualTo("0%");
|
||||
assertThat(ProfileSupport.share(1, 3)).isEqualByComparingTo("0.333333");
|
||||
assertThat(ProfileSupport.share(0, 0)).isZero();
|
||||
assertThat(ProfileSupport.metric("meanLength", new BigDecimal("12.345"), ValueType.DECIMAL).getFormatted()).isEqualTo("12.3");
|
||||
assertThat(ProfileSupport.metric("minLength", BigDecimal.TEN, ValueType.DECIMAL).getFormatted()).isEqualTo("10");
|
||||
assertThat(ProfileSupport.metric("max", null, ValueType.DECIMAL).getFormatted()).isNull();
|
||||
assertThatThrownBy(() -> MetricLabels.label("unknown", ValueType.COUNT)).isInstanceOf(NullPointerException.class).hasMessageContaining("Missing metric label");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host currency and custom display formats take precedence over numeric defaults.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fieldFormatsAndRawPrecision()
|
||||
{
|
||||
QFieldMetaData decimal = new QFieldMetaData("amount", QFieldType.DECIMAL);
|
||||
assertThat(AnalysisFormatting.numeric(decimal, "min", new BigDecimal("81753.32"))).isEqualTo("81,753.32");
|
||||
decimal.withDisplayFormat(DisplayFormat.CURRENCY);
|
||||
assertThat(AnalysisFormatting.numeric(decimal, "min", new BigDecimal("81753.32"))).isEqualTo("$81,753.32");
|
||||
var metric = AnalysisFormatting.numericMetric(decimal, "mean", new BigDecimal("125916.45167830173"));
|
||||
assertThat(metric.getValue()).isEqualTo(new BigDecimal("125916.4517"));
|
||||
assertThat(metric.getFormatted()).isEqualTo("$125,916.45");
|
||||
assertThat(AnalysisFormatting.numericMetric(decimal, "max", null).getValue()).isNull();
|
||||
QFieldMetaData integer = new QFieldMetaData("quantity", QFieldType.INTEGER);
|
||||
assertThat(AnalysisFormatting.numeric(integer, "max", new BigDecimal("12498"))).isEqualTo("12,498");
|
||||
assertThat(AnalysisFormatting.numeric(integer, "mean", new BigDecimal("12.345"))).isEqualTo("12.35");
|
||||
assertThat(AnalysisFormatting.numeric(integer, "stddev", new BigDecimal("12.345"))).isEqualTo("12.35");
|
||||
assertThat(AnalysisFormatting.numeric(integer.withDisplayFormat(DisplayFormat.DECIMAL1_COMMAS), "mean", new BigDecimal("12.345"))).isEqualTo("12.3");
|
||||
QFieldMetaData large = new QFieldMetaData("large", QFieldType.LONG).withDisplayFormat(DisplayFormat.COMMAS);
|
||||
assertThat(AnalysisFormatting.numeric(large, "sum", new BigDecimal("9000000000"))).isEqualTo("9,000,000,000");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nice boundaries remain exact at small scales, negative values and constants.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void niceEdgesAndLabels()
|
||||
{
|
||||
for(List<String> range : List.of(List.of("25317.13", "65781.79"), List.of("-0.0093", "0.0357"), List.of("42", "42"), List.of("0", "0"), List.of("-9", "905")))
|
||||
{
|
||||
BigDecimal low = new BigDecimal(range.getFirst());
|
||||
BigDecimal high = new BigDecimal(range.getLast());
|
||||
NiceBins bins = NiceBins.covering(low, high);
|
||||
assertThat(bins.count()).isBetween(10, 25);
|
||||
assertThat(bins.low()).isLessThanOrEqualTo(low);
|
||||
assertThat(bins.high()).isGreaterThanOrEqualTo(high);
|
||||
BigDecimal step = bins.high().subtract(bins.low()).divide(BigDecimal.valueOf(bins.count())).stripTrailingZeros();
|
||||
assertThat(step.scaleByPowerOfTen(step.scale() - step.precision() + 1).toPlainString()).isIn("1", "2", "2.5", "5");
|
||||
assertThat(bins.low().remainder(step)).isZero();
|
||||
assertThat(bins.high().remainder(step)).isZero();
|
||||
}
|
||||
QFieldMetaData field = new QFieldMetaData("amount", QFieldType.INTEGER);
|
||||
assertThat(AnalysisFormatting.interval(field, new BigDecimal("60000"), new BigDecimal("65000"))).isEqualTo("60,000 – 65,000");
|
||||
assertThat(AnalysisFormatting.interval(field, null, new BigDecimal("25000"))).isEqualTo("Below 25,000");
|
||||
assertThat(AnalysisFormatting.interval(field, new BigDecimal("65000"), null)).isEqualTo("65,000 and above");
|
||||
assertThat(AnalysisFormatting.interval(field, new BigDecimal("1.25"), new BigDecimal("1.5"))).isEqualTo("1.25 – 1.5");
|
||||
field.withDisplayFormat(DisplayFormat.CURRENCY);
|
||||
assertThat(AnalysisFormatting.interval(field, new BigDecimal("60000"), new BigDecimal("65000"))).isEqualTo("$60,000 – $65,000");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every supported unit has a human label without replacing its ISO coordinate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void dateUnitsAndDurations()
|
||||
{
|
||||
ZoneId zone = ZoneId.of("America/Chicago");
|
||||
LocalDate date = LocalDate.of(2024, 3, 4);
|
||||
Map<AnalysisBackend.DateUnit, String> labels = Map.of(
|
||||
AnalysisBackend.DateUnit.YEAR, "2024", AnalysisBackend.DateUnit.QUARTER, "2024 Q1", AnalysisBackend.DateUnit.MONTH, "Mar 2024",
|
||||
AnalysisBackend.DateUnit.WEEK, "Week of 2024-03-04", AnalysisBackend.DateUnit.DAY, "2024-03-04");
|
||||
labels.forEach((unit, label) -> assertThat(AnalysisFormatting.dateBucket(date, unit, zone)).isEqualTo(label));
|
||||
assertThat(AnalysisFormatting.dateBucket(Instant.parse("2024-03-04T19:00:00Z"), AnalysisBackend.DateUnit.HOUR, zone)).isEqualTo("2024-03-04 13:00");
|
||||
for(int i = 1; i <= 7; i++)
|
||||
{
|
||||
assertThat(AnalysisFormatting.dateBucket(i, AnalysisBackend.DateUnit.DAY_OF_WEEK, zone))
|
||||
.isEqualTo(List.of("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday").get(i - 1));
|
||||
}
|
||||
for(int i = 0; i < 24; i++)
|
||||
{
|
||||
assertThat(AnalysisFormatting.dateBucket(i, AnalysisBackend.DateUnit.HOUR_OF_DAY, zone)).isEqualTo(String.format(java.util.Locale.US, "%02d:00", i));
|
||||
}
|
||||
assertThat(AnalysisFormatting.duration(date, date.plusYears(48).plusMonths(2))).isEqualTo("48 years 2 months");
|
||||
assertThat(AnalysisFormatting.duration(date, date.plusDays(12))).isEqualTo("12 days");
|
||||
assertThat(AnalysisFormatting.duration(date, date.plusYears(1).plusMonths(1).plusDays(1))).isEqualTo("1 year 1 month 1 day");
|
||||
assertThat(AnalysisFormatting.duration(date, date)).isEqualTo("0 days");
|
||||
assertThat(ProfileSupport.metric("min", date, ValueType.DATE).getLabel()).isEqualTo("Earliest");
|
||||
assertThat(ProfileSupport.metric("max", date, ValueType.DATE).getLabel()).isEqualTo("Latest");
|
||||
assertThat(ProfileSupport.metric("min", date, ValueType.DATE).getFormatted()).isEqualTo("2024-03-04");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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.Set;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.CoreAnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.StandInAnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.interfaces.AggregateInterface;
|
||||
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.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryAggregateAction;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Native sample membership and capability-directed fallback behavior.
|
||||
*******************************************************************************/
|
||||
class CoreAnalysisBackendTest extends EngineFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Restore the global dispatcher after a capability-specific fixture.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void restoreModule()
|
||||
{
|
||||
QBackendModuleDispatcher.registerBackendModule(new MemoryBackendModule());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deterministic memory sampling scales every-k-th counts to the population.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sampledPopulation() throws Exception
|
||||
{
|
||||
config.withDefaultSampleThresholdRows(5).withDefaultSampleRows(4);
|
||||
assertThat(service.backend("orders")).isInstanceOf(CoreAnalysisBackend.class);
|
||||
var profile = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("amount"));
|
||||
assertThat(profile.getEstimate()).isTrue();
|
||||
assertThat(profile.getSampleRows()).isEqualTo(4);
|
||||
assertThat(profile.getNumeric().getMetrics().stream().filter(m -> m.getKey().equals("sum")).findFirst().orElseThrow().getValue().toString()).isEqualTo("1030");
|
||||
var counts = profile.getCounts().stream().collect(java.util.stream.Collectors.toMap(m -> m.getKey(), m -> m));
|
||||
assertThat(counts.get("rows").getValue()).isEqualTo(12L);
|
||||
assertThat(counts.get("rows").getEstimate()).isFalse();
|
||||
assertThat(counts.get("nonNull").getValue()).isEqualTo(Math.round(4.0 * 12 / 4));
|
||||
assertThat(profile.getTopValues()).allSatisfy(v ->
|
||||
{
|
||||
assertThat(v.getCount()).isEqualTo(Math.round(1.0 * 12 / 4));
|
||||
assertThat(v.getShare()).isEqualByComparingTo("0.25");
|
||||
});
|
||||
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(profile)).doesNotContain("\"operator\":\"IN\"");
|
||||
new MemoryEngineTest().assertDrills(profile);
|
||||
config.withDefaultMaxRows(0);
|
||||
service.getConfigResolver().invalidate();
|
||||
var empty = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("amount"));
|
||||
assertThat(empty.getEstimate()).isTrue();
|
||||
assertThat(empty.getSampleRows()).isEqualTo(4);
|
||||
assertThat(empty.getTruncated()).isFalse();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Rounding counts must not alter raw shares, and drills retain the user filter.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void filteredSampleRatios() throws Exception
|
||||
{
|
||||
config.withDefaultSampleThresholdRows(2).withDefaultSampleRows(4);
|
||||
var filter = com.kingsrook.qbits.dataanalysis.engine.DrillFilters.condition("id",
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.GREATER_THAN, 1);
|
||||
var profile = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("amount").withFilter(filter));
|
||||
// The filtered ids 2..12, sampled every third row, yield 2, 5, 8, 11.
|
||||
var sampledAmounts = java.util.Arrays.asList(0, 20, 40, null);
|
||||
long nonNull = sampledAmounts.stream().filter(java.util.Objects::nonNull).count();
|
||||
var counts = profile.getCounts().stream().collect(java.util.stream.Collectors.toMap(m -> m.getKey(), m -> m));
|
||||
assertThat(profile.getSampleRows()).isEqualTo(sampledAmounts.size());
|
||||
assertThat(counts.get("rows").getValue()).isEqualTo(11L);
|
||||
assertThat(counts.get("nonNull").getValue()).isEqualTo(Math.round(nonNull * 11.0 / sampledAmounts.size()));
|
||||
assertThat(counts.get("nulls").getValue()).isEqualTo(Math.round((sampledAmounts.size() - nonNull) * 11.0 / sampledAmounts.size()));
|
||||
assertThat(profile.getTopValues()).hasSize(4).allSatisfy(v ->
|
||||
{
|
||||
assertThat(v.getCount()).isEqualTo(Math.round(11.0 / sampledAmounts.size()));
|
||||
assertThat(v.getShare()).isEqualByComparingTo("0.25");
|
||||
});
|
||||
var exact = new StandInAnalysisBackend(service.getConfigResolver().resolve("orders"));
|
||||
assertThat(exact.count("orders", counts.get("rows").getDrill().getFilter())).isEqualTo(11);
|
||||
assertThat(exact.count("orders", counts.get("nonNull").getDrill().getFilter())).isEqualTo(10);
|
||||
var twenty = profile.getTopValues().stream().filter(v -> v.getValue() != null && v.getValue().toString().equals("20")).findFirst().orElseThrow();
|
||||
assertThat(exact.count("orders", twenty.getDrill().getFilter())).isEqualTo(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty samples keep the population drill; inspecting every row is exact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void samplingBoundaries() throws Exception
|
||||
{
|
||||
config.withDefaultSampleThresholdRows(2).withDefaultSampleRows(0);
|
||||
var empty = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("amount"));
|
||||
assertThat(empty.getSampleRows()).isZero();
|
||||
assertThat(empty.getCounts().getFirst().getValue()).isEqualTo(12L);
|
||||
assertThat(empty.getCounts().get(1).getValue()).isEqualTo(0L);
|
||||
config.withDefaultSampleRows(100);
|
||||
service.getConfigResolver().invalidate();
|
||||
var scope = service.backend("orders").scope("orders", null, false);
|
||||
assertThat(scope.samplePercent()).isNull();
|
||||
assertThat(scope.estimate()).isFalse();
|
||||
assertThat(scope.rowsScanned()).isEqualTo(12);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A non-random estimate has one boundary bind and population-wide drills.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void prefixBoundary() throws Exception
|
||||
{
|
||||
capabilities(Set.of());
|
||||
config.withDefaultSampleThresholdRows(2).withDefaultSampleRows(4);
|
||||
var filter = com.kingsrook.qbits.dataanalysis.engine.DrillFilters.condition("id",
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.GREATER_THAN, 2);
|
||||
var backend = service.backend("orders");
|
||||
var scope = backend.scope("orders", filter, false);
|
||||
assertThat(scope.filter().getSubFilters().get(1).getCriteria()).singleElement().satisfies(c ->
|
||||
{
|
||||
assertThat(c.getFieldName()).isEqualTo("id");
|
||||
assertThat(c.getOperator()).isEqualTo(com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.LESS_THAN_OR_EQUALS);
|
||||
assertThat(c.getValues()).containsExactly(6);
|
||||
});
|
||||
var profile = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("quantity").withFilter(filter));
|
||||
assertThat(profile.getCounts().getFirst().getValue()).isEqualTo(10L);
|
||||
assertThat(profile.getCounts().stream().filter(m -> m.getKey().equals("nonNull")).findFirst().orElseThrow().getValue()).isEqualTo(4L);
|
||||
assertThat(profile.getWarnings()).contains("Estimate from the first 4 rows by id");
|
||||
var drill = profile.getCounts().getFirst().getDrill();
|
||||
assertThat(new StandInAnalysisBackend(service.getConfigResolver().resolve("orders")).count("orders", drill.getFilter())).isEqualTo(10);
|
||||
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(profile)).doesNotContain("\"operator\":\"IN\"");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty capabilities select the stand-in; partial support uses core plus fallback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallbackSelection() throws Exception
|
||||
{
|
||||
capabilities(Set.of());
|
||||
assertThat(service.backend("orders")).isExactlyInstanceOf(StandInAnalysisBackend.class);
|
||||
capabilities(Set.of(AggregateCapability.STDDEV));
|
||||
assertThat(service.backend("orders")).isInstanceOf(CoreAnalysisBackend.class);
|
||||
var profile = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("amount"));
|
||||
assertThat(profile.getNumeric().getMetrics().stream().filter(m -> m.getKey().equals("p50")).findFirst().orElseThrow().getValue().toString()).isEqualTo("20.0");
|
||||
new MemoryEngineTest().assertDrills(profile);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Advertise selected capabilities while retaining the fixture's actual actions.
|
||||
*******************************************************************************/
|
||||
private void capabilities(Set<AggregateCapability> supported)
|
||||
{
|
||||
LimitedMemoryBackend.supported = supported;
|
||||
QBackendModuleDispatcher.registerBackendModule(new LimitedMemoryBackend());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public no-argument backend class required by the module dispatcher.
|
||||
*******************************************************************************/
|
||||
public static class LimitedMemoryBackend extends MemoryBackendModule
|
||||
{
|
||||
private static Set<AggregateCapability> supported = Set.of();
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain real memory execution with a deliberately reduced feature set.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AggregateInterface getAggregateInterface()
|
||||
{
|
||||
return (new MemoryAggregateAction()
|
||||
{
|
||||
@Override
|
||||
public Set<AggregateCapability> getSupportedCapabilities(AggregateInput input)
|
||||
{
|
||||
return (supported);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiRequest;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP session, schema and drill round-trip acceptance tests.
|
||||
*******************************************************************************/
|
||||
class DataAnalysisApiTest extends EngineFixture
|
||||
{
|
||||
private QApplicationJavalinServer server;
|
||||
private UiApiInvoker invoker;
|
||||
private String caller = "analysis-user";
|
||||
|
||||
/*******************************************************************************
|
||||
** Give MOCK sessions only the grants represented by their caller cookie.
|
||||
*******************************************************************************/
|
||||
public static class Sessions implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Build the host's ordinary user permission and record-lock policy.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
QSession grants = TestSupport.admin().withPermissions("orders.read", "customer.read").withSecurityKeyValue("orders.all", true);
|
||||
session.setPermissions(grants.getPermissions());
|
||||
session.setSecurityKeyValues(grants.getSecurityKeyValues());
|
||||
if(!"denied".equals(session.getUuid()))
|
||||
{
|
||||
session.withPermission("orders.analyze");
|
||||
}
|
||||
session.withSecurityKeyValue("data_analysis_user", session.getUser().getIdReference());
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Start on an OS-selected port using the actual spec router and authentication.
|
||||
*******************************************************************************/
|
||||
private void start() throws Exception
|
||||
{
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("http-test").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(Sessions.class)));
|
||||
QInstance fixture = instance;
|
||||
server = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (fixture);
|
||||
}
|
||||
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true);
|
||||
server.start();
|
||||
invoker = UiApiInvoker.forServer(server);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop the server before the fixture releases context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stop()
|
||||
{
|
||||
if(server != null)
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
QJavalinImplementation.setSessionCookieHttpOnly(false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke through loopback, forwarding the actual session cookie.
|
||||
*******************************************************************************/
|
||||
private JsonNode call(HttpMethod method, String path, Object body, int status) throws Exception
|
||||
{
|
||||
var request = new UiApiRequest().withMethod(method).withPath(DataAnalysisApi.PREFIX + path)
|
||||
.withPathParam("table", "orders").withSessionUUID(caller).withUserTimezone("America/Chicago");
|
||||
if(body != null)
|
||||
{
|
||||
request.withJsonBody(body);
|
||||
}
|
||||
var response = invoker.invoke(request);
|
||||
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(status);
|
||||
return (response.getJson());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All operations, assistant discovery, OpenAPI and count-drill equivalence.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void happyPathsAndDrills() throws Exception
|
||||
{
|
||||
start();
|
||||
JsonNode capabilities = call(HttpMethod.GET, "/tables/{table}/capabilities", null, 200);
|
||||
assertThat(capabilities.path("canAnalyze").asBoolean()).isTrue();
|
||||
assertThat(capabilities.path("backendCapabilities").toString()).contains("NUMERIC_BUCKET");
|
||||
assertThat(capabilities.path("fields").toString()).doesNotContain("secret").contains("CATEGORY", "HISTOGRAM");
|
||||
for(String field : List.of("amount", "note", "created", "order_date", "paid", "status"))
|
||||
{
|
||||
checkDrills(call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", field), 200));
|
||||
}
|
||||
checkDrills(call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount", "filter",
|
||||
Map.of("criteria", List.of(Map.of("fieldName", "created", "operator", "EQUALS", "values", List.of("2026-01-01T12:00:00Z"))))), 200));
|
||||
checkDrills(call(HttpMethod.POST, "/tables/{table}/distinct-values", Map.of("fieldName", "status", "pageSize", 1), 200));
|
||||
checkDrills(call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of("fields", List.of("amount", "note")), 200));
|
||||
checkDrills(call(HttpMethod.POST, "/tables/{table}/duplicates", Map.of("fields", List.of("quantity"), "limit", 2), 200));
|
||||
for(String operation : List.of("COLUMN_PROFILE", "TABLE_PROFILE"))
|
||||
{
|
||||
JsonNode started = call(HttpMethod.POST, "/runs", Map.of("operation", operation, "tableName", "orders", "request",
|
||||
operation.equals("COLUMN_PROFILE") ? Map.of("fieldName", "quantity") : Map.of("fields", List.of("paid"))), 200);
|
||||
JsonNode run = null;
|
||||
for(int i = 0; i < 100; i++)
|
||||
{
|
||||
var response = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath(DataAnalysisApi.PREFIX + "/runs/{runId}")
|
||||
.withPathParam("runId", started.path("runId").asText()).withSessionUUID(caller).withUserTimezone("America/Chicago"));
|
||||
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(200);
|
||||
run = response.getJson();
|
||||
if(!run.path("status").asText().equals("RUNNING"))
|
||||
{
|
||||
break;
|
||||
}
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertThat(run.path("status").asText()).isEqualTo("COMPLETE");
|
||||
var denied = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath(DataAnalysisApi.PREFIX + "/runs/{runId}")
|
||||
.withPathParam("runId", started.path("runId").asText()).withSessionUUID("another-owner"));
|
||||
assertThat(denied.getStatus()).isEqualTo(403);
|
||||
assertThat(run.path(operation.equals("COLUMN_PROFILE") ? "columnProfile" : "tableProfile").isObject()).isTrue();
|
||||
}
|
||||
assertThat(invoker.listOperations().stream().filter(o -> o.pathTemplate().startsWith(DataAnalysisApi.PREFIX)).toList()).hasSize(7);
|
||||
var client = java.net.http.HttpClient.newHttpClient();
|
||||
var response = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create("http://127.0.0.1:" + server.getBoundPort() + "/qqq/v1/openapi.json")).build(), java.net.http.HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
JsonNode api = new ObjectMapper().readTree(response.body());
|
||||
assertThat(api.path("paths").has("/qqq/v1/qbit/data-analysis/tables/{table}/column-profile")).isTrue();
|
||||
assertThat(api.path("components").path("schemas").has("DataAnalysisMetricV1")).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Estimate payloads preserve exact totals and send drills to all matching rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void estimateDrills() throws Exception
|
||||
{
|
||||
config.withDefaultSampleThresholdRows(2).withDefaultSampleRows(4);
|
||||
instance.withSupplementalMetaData(new DataAnalysisRuntime(config));
|
||||
start();
|
||||
JsonNode profile = call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount"), 200);
|
||||
assertThat(profile.path("estimate").asBoolean()).isTrue();
|
||||
assertThat(profile.path("sampleRows").asLong()).isEqualTo(4);
|
||||
JsonNode rows = profile.path("counts").get(0);
|
||||
assertThat(rows.path("value").asLong()).isEqualTo(12);
|
||||
assertThat(rows.path("estimate").asBoolean()).isFalse();
|
||||
assertThat(profile.path("counts").get(1).path("formatted").asText()).startsWith("≈ ");
|
||||
assertThat(profile.toString()).doesNotContain("\"operator\":\"IN\"");
|
||||
var response = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/count")
|
||||
.withPathParam("tableName", "orders").withJsonBody(Map.of("filter", rows.path("drill").path("filter")))
|
||||
.withSessionUUID(caller).withUserTimezone("America/Chicago"));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.getJson().path("count").asLong()).isEqualTo(12);
|
||||
checkDrills(profile);
|
||||
JsonNode table = call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of("fields", List.of("amount", "quantity")), 200);
|
||||
assertThat(table.path("estimate").asBoolean()).isTrue();
|
||||
assertThat(table.path("rowCount").path("value").asLong()).isEqualTo(12);
|
||||
checkDrills(table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recursively post returned count drills to the core count endpoint.
|
||||
*******************************************************************************/
|
||||
private void checkDrills(JsonNode node) throws Exception
|
||||
{
|
||||
if(node.path("estimate").asBoolean(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(node.isObject() && node.hasNonNull("drill"))
|
||||
{
|
||||
JsonNode expected = node.has("count") ? node.get("count") : node.has("y") ? node.get("y") : "COUNT".equals(node.path("valueType").asText()) ? node.get("value") : null;
|
||||
if(expected != null && expected.isNumber())
|
||||
{
|
||||
JsonNode drill = node.get("drill");
|
||||
var response = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/count")
|
||||
.withPathParam("tableName", drill.path("tableName").asText()).withJsonBody(Map.of("filter", drill.get("filter"))).withSessionUUID(caller).withUserTimezone("America/Chicago"));
|
||||
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(200);
|
||||
assertThat(response.getJson().path("count").asLong()).as(drill.toString()).isEqualTo(expected.asLong());
|
||||
}
|
||||
}
|
||||
if(node.isContainerNode())
|
||||
{
|
||||
for(JsonNode child : node)
|
||||
{
|
||||
checkDrills(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Denials, locked fields, missing tables and malformed request bodies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errors() throws Exception
|
||||
{
|
||||
start();
|
||||
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "secret"), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "missing"), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of(), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", 1), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount", "exact", "yes"), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/distinct-values", Map.of("fieldName", "note", "pageSize", 0), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/duplicates", Map.of("fields", List.of()), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of("fields", "amount"), 400);
|
||||
call(HttpMethod.POST, "/runs", Map.of("operation", "PIVOT"), 400);
|
||||
call(HttpMethod.POST, "/runs", com.fasterxml.jackson.databind.node.NullNode.getInstance(), 400);
|
||||
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount", "filter",
|
||||
Map.of("criteria", List.of(Map.of("fieldName", "secret", "operator", "EQUALS", "values", List.of("hidden"))))), 400);
|
||||
var absent = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath(DataAnalysisApi.PREFIX + "/tables/{table}/capabilities")
|
||||
.withPathParam("table", "missing").withSessionUUID(caller).withUserTimezone("America/Chicago"));
|
||||
assertThat(absent.getStatus()).isEqualTo(404);
|
||||
caller = "denied";
|
||||
assertThat(call(HttpMethod.GET, "/tables/{table}/capabilities", null, 200).path("canAnalyze").asBoolean()).isFalse();
|
||||
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount"), 403);
|
||||
call(HttpMethod.POST, "/tables/{table}/distinct-values", Map.of("fieldName", "amount"), 403);
|
||||
call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of(), 403);
|
||||
call(HttpMethod.POST, "/tables/{table}/duplicates", Map.of("fields", List.of("amount")), 403);
|
||||
call(HttpMethod.POST, "/runs", Map.of("operation", "COLUMN_PROFILE", "tableName", "orders", "request", Map.of("fieldName", "amount")), 403);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Generated fragment is committed verbatim and has only the seven operations.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
MiddlewareVersionV1 version = new MiddlewareVersionV1();
|
||||
version.setQInstance(instance);
|
||||
String yaml = UiApiContract.generateExtensionYaml(version, DataAnalysisApi.extension());
|
||||
Path path = Path.of("src/main/resources/openapi/qqq-v1/data-analysis.yaml");
|
||||
if(Boolean.getBoolean("openapi.write"))
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, yaml);
|
||||
}
|
||||
assertThat(Files.exists(path)).as("Regenerate with ./mvnw -o test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.0-DATAANALYSIS-SNAPSHOT").isTrue();
|
||||
assertThat(Files.readString(path).replace("\r\n", "\n")).as("OpenAPI changed: regenerate with -Dopenapi.write=true").isEqualTo(yaml.replace("\r\n", "\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real home counts, empty states and permission-scoped results.
|
||||
*******************************************************************************/
|
||||
class DataAnalysisHomeTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Release the thread-local context.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty counts explain why; missing permissions are not represented as zero.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyAndDenied() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.read", "data_analysis.run.read"));
|
||||
String html = DataAnalysisHome.render(Instant.now());
|
||||
assertThat(html).contains("Mine: 0", "Starred by me: 0", "No saved analyses yet",
|
||||
"No watches yet.", "No analysis runs yet.", "View all", "Open any table and choose Analyze on a column header to explore its data.", "<h3>");
|
||||
var widget = new DataAnalysisHome().render(new RenderWidgetInput()).getWidgetData();
|
||||
assertThat(widget).isInstanceOf(com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML.class);
|
||||
assertThat(((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) widget).getType()).isEqualTo("html");
|
||||
assertThat(html).contains("·").doesNotContain("Â", "later", "phase", "<table", "color:", "width:360", "width: 360");
|
||||
assertThat(html.chars().allMatch(c -> c < 128)).isTrue();
|
||||
QContext.init(instance, TestSupport.user("alice"));
|
||||
html = DataAnalysisHome.render(Instant.now());
|
||||
assertThat(html).contains("you need data_analysis.saved.read", "you need data_analysis.watch.read").doesNotContain("Mine: 0", "Complete: 0");
|
||||
QContext.init(instance, new QSession());
|
||||
assertThat(DataAnalysisHome.render(Instant.now())).contains("Sign in to see your saved analyses");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Different owners, shared flags, stars, disabled watches and date boundaries.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void countsUseCurrentUserAndRealRows() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
Instant now = Instant.parse("2026-10-13T12:00:00Z");
|
||||
QRecord mine = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", true));
|
||||
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false));
|
||||
QRecord bob = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", true));
|
||||
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", mine.getValue("id")).withValue("user_id", "alice"));
|
||||
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", bob.getValue("id")).withValue("user_id", "alice"));
|
||||
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", mine.getValue("id")).withValue("user_id", "bob"));
|
||||
TestSupport.insert(DataAnalysisTables.WATCH, new QRecord().withValue("detector", "SPIKE").withValue("enabled", true).withValue("state", "FIRING"));
|
||||
TestSupport.insert(DataAnalysisTables.WATCH, new QRecord().withValue("detector", "DROP").withValue("enabled", true));
|
||||
TestSupport.insert(DataAnalysisTables.WATCH, new QRecord().withValue("detector", "DROP").withValue("enabled", false));
|
||||
run("alice", "COMPLETE", now);
|
||||
run("alice", "COMPLETE", now.minus(7, ChronoUnit.DAYS));
|
||||
run("alice", "FAILED", now.minus(1, ChronoUnit.DAYS));
|
||||
run("alice", "COMPLETE", now.minus(7, ChronoUnit.DAYS).minusSeconds(1));
|
||||
run("alice", "FAILED", now.plusSeconds(1));
|
||||
run("alice", "RUNNING", now);
|
||||
run("bob", "FAILED", now);
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.watch.read", "data_analysis.run.read"));
|
||||
String html = DataAnalysisHome.render(now);
|
||||
assertThat(html).contains("Mine: 2", "Shared (visible to you): 1", "Starred by me: 1", "Enabled: 2", "Firing: 1", "Recent analysis runs", "Complete", "Failed", "Running", "Not finished")
|
||||
.doesNotContain("No saved analyses", "No watches yet", "No analysis runs yet");
|
||||
QContext.init(instance, TestSupport.user("bob").withPermissions("data_analysis.saved.read", "data_analysis.run.read"));
|
||||
html = DataAnalysisHome.render(now);
|
||||
assertThat(html).contains("Mine: 1", "Shared (visible to you): 1", "Starred by me: 0", "Failed", "you need data_analysis.watch.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest five remain owner-scoped even with administrator bypass and escape HTML.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recentRunDetailsAndLimit() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
instance.getTable(DataAnalysisTables.SAVED).withLabel("Saved <analysis> & review");
|
||||
Instant now = Instant.parse("2026-10-13T23:10:00Z");
|
||||
for(int i = 0; i < 7; i++)
|
||||
{
|
||||
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", DataAnalysisTables.SAVED)
|
||||
.withValue("operation", "COLUMN_PROFILE").withValue("user_id", "admin").withValue("status", "COMPLETE")
|
||||
.withValue("started_at", now.minusSeconds(i * 60L)).withValue("finished_at", now.minusSeconds(i * 60L)));
|
||||
}
|
||||
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", "private_bob_table")
|
||||
.withValue("operation", "TABLE_PROFILE").withValue("user_id", "bob").withValue("status", "FAILED").withValue("started_at", now));
|
||||
QContext.init(instance, TestSupport.admin().withValue(QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago"));
|
||||
String html = DataAnalysisHome.render(now);
|
||||
assertThat(html).contains("Saved <analysis> & review", "Column Profile", "2026-10-13 18:10", "2026-10-13 18:06", "<ul", "<dl")
|
||||
.doesNotContain("private_bob_table", "2026-10-13 18:05", "<analysis>");
|
||||
assertThat(html.split("<li>", -1)).hasSize(6);
|
||||
assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "/1\"");
|
||||
assertThat(html).contains("href=\"" + instance.getTablePath(DataAnalysisTables.RUN) + "\">View all</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a real run row with an explicit owner and start time.
|
||||
*******************************************************************************/
|
||||
private static void run(String user, String status, Instant started) throws Exception
|
||||
{
|
||||
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", "orders").withValue("operation", "COLUMN_PROFILE")
|
||||
.withValue("user_id", user).withValue("status", status).withValue("started_at", started));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
|
||||
import com.kingsrook.qbits.dataanalysis.metadata.DataAnalysisMetaDataProducer;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfigValidationException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata, configuration and generated schema contracts.
|
||||
*******************************************************************************/
|
||||
class DataAnalysisMetadataTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Release thread-local context between tests.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full production, validation, navigation, locks, enums and permission catalog.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void producerAndMetadata() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
new QInstanceValidator().validate(instance);
|
||||
assertThat(instance.getTables()).containsOnlyKeys(DataAnalysisTables.ALL);
|
||||
assertThat(instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("data_analysis_"))).hasSize(13);
|
||||
assertThat(instance.getJoins()).hasSize(3);
|
||||
assertThat(instance.getWidgets()).hasSize(4).containsKey(DataAnalysisHome.HOME_WIDGET);
|
||||
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getProcesses()).isNullOrEmpty();
|
||||
assertThat(instance.getApp("dataAnalysis").getLabel()).isEqualTo("Analyses");
|
||||
assertThat(instance.getApp("dataAnalysis").getSections().getFirst().getTables()).containsExactlyElementsOf(DataAnalysisTables.NAV_TABLES);
|
||||
assertThat(instance.getTable(DataAnalysisTables.SAVED).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
assertThat(instance.getTable(DataAnalysisTables.TABLE_CONFIG).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
assertThat(instance.getTable(DataAnalysisTables.TABLE_CONFIG).getUniqueKeys().getFirst().getFieldNames()).containsExactly("table_name");
|
||||
assertThat(instance.getTable(DataAnalysisTables.SAVED_STAR).getUniqueKeys().getFirst().getFieldNames()).containsExactly("saved_id", "user_id");
|
||||
for(String name : List.of(DataAnalysisTables.SAVED, DataAnalysisTables.SAVED_STAR, DataAnalysisTables.SCHEDULE))
|
||||
{
|
||||
assertThat(instance.getTable(name).getPermissionRules().getPermissionBaseName()).isEqualTo("data_analysis.saved");
|
||||
}
|
||||
assertThat(instance.getTable(DataAnalysisTables.WATCH).getPermissionRules().getPermissionBaseName()).isEqualTo("data_analysis.watch");
|
||||
assertThat(instance.getTable(DataAnalysisTables.RUN).getPermissionRules().getPermissionBaseName()).isEqualTo("data_analysis.run");
|
||||
assertThat(instance.getTable(DataAnalysisTables.TABLE_CONFIG).getPermissionRules().getPermissionBaseName()).isEqualTo("data_analysis.settings");
|
||||
for(String name : List.of(DataAnalysisTables.SAVED, DataAnalysisTables.SAVED_STAR, DataAnalysisTables.RUN))
|
||||
{
|
||||
RecordSecurityLock lock = instance.getTable(name).getRecordSecurityLocks().getFirst();
|
||||
assertThat(lock.getSecurityKeyType()).isEqualTo("data_analysis_user");
|
||||
assertThat(lock.getFieldName()).isEqualTo(name.equals(DataAnalysisTables.SAVED) ? "owner_user_id" : "user_id");
|
||||
assertThat(lock.getNullValueBehavior()).isEqualTo(RecordSecurityLock.NullValueBehavior.DENY);
|
||||
assertThat(lock.getLockScope()).isEqualTo(RecordSecurityLock.LockScope.READ_AND_WRITE);
|
||||
}
|
||||
assertThat(instance.getSecurityKeyType("data_analysis_user").getAllAccessKeyName()).isEqualTo("data_analysis.admin");
|
||||
assertThat(PermissionsHelper.getAllAvailablePermissionNames(instance)).contains("data_analysis.settings.read", "data_analysis.settings.edit", "data_analysis.run.read", "data_analysis.run.delete", "data_analysis.saved.read", "data_analysis.saved.insert",
|
||||
"data_analysis.saved.edit", "data_analysis.saved.delete", "data_analysis.watch.read", "data_analysis.watch.insert", "data_analysis.watch.edit", "data_analysis.watch.delete")
|
||||
.doesNotContain("data_analysis.admin.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Custom app names, parent linkage and qbit identity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void producerConfiguration() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName("memory")
|
||||
.withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class));
|
||||
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin"));
|
||||
DataAnalysisQBitConfig config = new DataAnalysisQBitConfig().withBackendName("memory").withAppName("customAnalysis").withAppLabel("Insights").withParentAppName("admin");
|
||||
DataAnalysisQBitProducer producer = new DataAnalysisQBitProducer().withQBitConfig(config).withNamespace("analysis");
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getNamespace()).isEqualTo("analysis");
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
|
||||
assertThat(producer.getQBitMetaData().getGroupId()).isEqualTo("com.kingsrook.qbits");
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-data-analysis");
|
||||
assertThat(producer.getPackageNameForFindingMetaDataProducers()).endsWith(".metadata");
|
||||
producer.produce(instance);
|
||||
assertThat(instance.getApp("customAnalysis").getParentAppName()).isEqualTo("admin");
|
||||
assertThat(instance.getApp("admin").getChildren()).contains(instance.getApp("customAnalysis"));
|
||||
assertThat(new DataAnalysisQBitProducer().isEnabled()).isFalse();
|
||||
assertThat(new DataAnalysisMetaDataProducer().isEnabled()).isFalse();
|
||||
assertThatThrownBy(() -> new DataAnalysisQBitProducer().withQBitConfig(new DataAnalysisQBitConfig()).produce(instance))
|
||||
.isInstanceOf(QBitConfigValidationException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Defaults and meaningful code-level overrides remain available to later phases.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configDefaultsAndOverrides() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
DataAnalysisQBitConfig config = new DataAnalysisQBitConfig().withBackendName("memory");
|
||||
assertThat(config.getDefaultMaxRows()).isEqualTo(1_000_000);
|
||||
assertThat(config.getDefaultSampleThresholdRows()).isEqualTo(1_000_000);
|
||||
assertThat(config.getDefaultSampleRows()).isEqualTo(100_000);
|
||||
assertThat(config.getDefaultMinGroupSize()).isZero();
|
||||
assertThat(config.getDefaultCacheTtlMinutes()).isEqualTo(60);
|
||||
assertThat(config.getIncludeTables()).isEmpty();
|
||||
assertThat(config.getExcludeTables()).isEmpty();
|
||||
assertThat(config.getTableDefaults()).isEmpty();
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("memory");
|
||||
TableAnalysisDefaults defaults = new TableAnalysisDefaults().withEnabled(true).withMaxRows(200).withSampleThresholdRows(100)
|
||||
.withSampleRows(50).withMinGroupSize(5).withCacheTtlMinutes(10).withAnalyzableFields(Set.of("amount")).withExcludedFields(Set.of("secret"));
|
||||
config.withSchedulerName("scheduler").withIncludeTables(Set.of("orders")).withExcludeTables(Set.of("users")).withTableDefaults(Map.of("orders", defaults));
|
||||
config.validate(instance);
|
||||
assertThat(config.getSchedulerName()).isEqualTo("scheduler");
|
||||
assertThat(config.getTableDefaults().get("orders")).isSameAs(defaults);
|
||||
assertThat(config.getIncludeTables()).containsExactly("orders");
|
||||
assertThat(config.getExcludeTables()).containsExactly("users");
|
||||
assertThat(defaults.getEnabled()).isTrue();
|
||||
assertThat(defaults.getMaxRows()).isEqualTo(200);
|
||||
assertThat(defaults.getSampleThresholdRows()).isEqualTo(100);
|
||||
assertThat(defaults.getSampleRows()).isEqualTo(50);
|
||||
assertThat(defaults.getMinGroupSize()).isEqualTo(5);
|
||||
assertThat(defaults.getCacheTtlMinutes()).isEqualTo(10);
|
||||
assertThat(defaults.getAnalyzableFields()).containsExactly("amount");
|
||||
assertThat(defaults.getExcludedFields()).containsExactly("secret");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Report every invalid number and absent backend instead of failing at runtime.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invalidConfig() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
DataAnalysisQBitConfig config = new DataAnalysisQBitConfig().withBackendName("missing").withDefaultMaxRows(-1).withDefaultSampleThresholdRows(-1)
|
||||
.withDefaultSampleRows(-1).withDefaultMinGroupSize(-1).withDefaultCacheTtlMinutes(-1);
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(instance, errors);
|
||||
assertThat(errors).hasSize(6);
|
||||
TableAnalysisDefaults defaults = new TableAnalysisDefaults().withMaxRows(-1).withSampleThresholdRows(-1).withSampleRows(-1).withMinGroupSize(-1).withCacheTtlMinutes(-1);
|
||||
config.withTableDefaults(Map.of("orders", defaults)).withBackendName("memory").withDefaultMaxRows(0).withDefaultSampleThresholdRows(0)
|
||||
.withDefaultSampleRows(0).withDefaultMinGroupSize(0).withDefaultCacheTtlMinutes(0);
|
||||
errors.clear();
|
||||
config.validate(instance, errors);
|
||||
assertThat(errors).hasSize(5).allMatch(error -> error.startsWith("tableDefaults[orders]"));
|
||||
config.withTableDefaults(Map.of("orders", new TableAnalysisDefaults()));
|
||||
config.validate(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DDL includes every field, inferred backend column, SQL type and unique key.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void schema()
|
||||
{
|
||||
String ddl = DataAnalysisSchema.postgresScript(new DataAnalysisQBitConfig().withBackendName("database"));
|
||||
for(String name : DataAnalysisTables.ALL)
|
||||
{
|
||||
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS " + name + " (");
|
||||
}
|
||||
assertThat(ddl).contains("id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", "create_date TIMESTAMP WITHOUT TIME ZONE",
|
||||
"modify_date TIMESTAMP WITHOUT TIME ZONE", "rows_scanned BIGINT", "result_json TEXT", "shared BOOLEAN", "day INTEGER",
|
||||
"owner_user_id VARCHAR(250) NOT NULL", "CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_table_config_1 ON data_analysis_table_config (table_name)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_data_analysis_saved_star_1 ON data_analysis_saved_star (saved_id, user_id)");
|
||||
assertThat(ddl).endsWith(";\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
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;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission enforcement and owner isolation through real core actions.
|
||||
*******************************************************************************/
|
||||
class DataAnalysisSecurityTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Release thread-local state.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective table permission bases and the READ plus analyze conjunction.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void analyzeRequiresBothPermissions() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
QTableMetaData table = new QTableMetaData().withName("orders")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
instance.addTable(table);
|
||||
QContext.init(instance, TestSupport.user("alice"));
|
||||
assertThat(DataAnalysisPermissions.analyzePermissionName(table)).isEqualTo("orders.analyze");
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isFalse();
|
||||
QContext.getQSession().withPermission("orders.analyze");
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isFalse();
|
||||
QContext.getQSession().withPermission("orders.read");
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isTrue();
|
||||
table.getPermissionRules().withPermissionBaseName("sales.orders");
|
||||
assertThat(DataAnalysisPermissions.analyzePermissionName(table)).isEqualTo("sales.orders.analyze");
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isFalse();
|
||||
QContext.getQSession().withPermissions("sales.orders.read", "sales.orders.analyze");
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isTrue();
|
||||
QContext.init(instance, TestSupport.user("alice").withPermission("sales.orders.read"));
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isFalse();
|
||||
instance.withEnforcePermissionsInCoreActions(false);
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isFalse();
|
||||
QContext.init(instance, TestSupport.user("alice").withPermission("sales.orders.analyze"));
|
||||
assertThat(DataAnalysisPermissions.canAnalyze(table)).isFalse();
|
||||
table.withPermissionRules(null);
|
||||
instance.withDefaultPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS).withPermissionBaseName("defaultBase"));
|
||||
assertThat(DataAnalysisPermissions.analyzePermissionName(table)).isEqualTo("defaultBase.analyze");
|
||||
table.withPermissionRules(new QPermissionRules().withPermissionBaseName(" "));
|
||||
assertThat(DataAnalysisPermissions.analyzePermissionName(table)).isEqualTo("orders.analyze");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The core permission catalog honors configured inclusion and exclusion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void analyzeCatalog() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance().withEnforcePermissionsInCoreActions(true);
|
||||
instance.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName("memory").withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class));
|
||||
for(String name : List.of("orders", "excluded", "outside"))
|
||||
{
|
||||
instance.addTable(new QTableMetaData().withName(name).withLabel(name).withPermissionRules(
|
||||
new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)));
|
||||
}
|
||||
new DataAnalysisQBitProducer().withQBitConfig(new DataAnalysisQBitConfig().withBackendName("memory")
|
||||
.withIncludeTables(java.util.Set.of("orders", "excluded")).withExcludeTables(java.util.Set.of("excluded"))).produce(instance);
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissions(instance)).anySatisfy(permission ->
|
||||
{
|
||||
assertThat(permission.getName()).isEqualTo("orders.analyze");
|
||||
assertThat(permission.getPermissionType()).isEqualTo("Analyze");
|
||||
}).noneMatch(permission -> permission.getName().equals("excluded.analyze") || permission.getName().equals("outside.analyze")
|
||||
|| permission.getName().startsWith("data_analysis") && permission.getName().endsWith(".analyze"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Saved, star and run locks hide others even when the shared flag is true.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ownerIsolation() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
QRecord mine = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false));
|
||||
QRecord other = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("bob", true));
|
||||
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", mine.getValue("id")).withValue("user_id", "alice"));
|
||||
TestSupport.insert(DataAnalysisTables.SAVED_STAR, new QRecord().withValue("saved_id", other.getValue("id")).withValue("user_id", "bob"));
|
||||
for(String owner : List.of("alice", "bob"))
|
||||
{
|
||||
TestSupport.insert(DataAnalysisTables.RUN, new QRecord().withValue("table_name", "orders").withValue("operation", "profile").withValue("user_id", owner));
|
||||
}
|
||||
TestSupport.insert(DataAnalysisTables.SCHEDULE, new QRecord().withValue("saved_id", mine.getValue("id")));
|
||||
TestSupport.insert(DataAnalysisTables.SCHEDULE, new QRecord().withValue("saved_id", other.getValue("id")));
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.run.read"));
|
||||
assertThat(query(DataAnalysisTables.SAVED)).extracting(r -> r.getValueString("owner_user_id")).containsExactly("alice");
|
||||
assertThat(query(DataAnalysisTables.SAVED_STAR)).extracting(r -> r.getValueString("user_id")).containsExactly("alice");
|
||||
assertThat(query(DataAnalysisTables.RUN)).extracting(r -> r.getValueString("user_id")).containsExactly("alice");
|
||||
assertThat(query(DataAnalysisTables.SCHEDULE)).extracting(r -> r.getValue("saved_id")).containsExactly(mine.getValue("id"));
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.admin"));
|
||||
assertThat(query(DataAnalysisTables.SAVED)).hasSize(1);
|
||||
QContext.getQSession().withSecurityKeyValue("data_analysis.admin", true);
|
||||
assertThat(query(DataAnalysisTables.SAVED)).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table READ does not grant writes; settings and run writes require admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void writesAndReadPermissions() throws Exception
|
||||
{
|
||||
QInstance instance = TestSupport.instance();
|
||||
QRecord saved = TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false));
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions("data_analysis.saved.read", "data_analysis.run.read"));
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(DataAnalysisTables.RUN), DataAnalysisTables.RUN, TablePermissionSubType.READ)).isTrue();
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(DataAnalysisTables.TABLE_CONFIG), DataAnalysisTables.TABLE_CONFIG, TablePermissionSubType.READ)).isFalse();
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(TestSupport.saved("alice", false)))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(DataAnalysisTables.SAVED).withRecords(List.of(new QRecord().withValue("id", saved.getValue("id")).withValue("name", "Changed")))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(DataAnalysisTables.SAVED).withPrimaryKeys(List.of(saved.getValue("id")))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(DataAnalysisTables.RUN).withRecords(List.of(new QRecord().withValue("user_id", "alice")))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.getQSession().withPermission("data_analysis.saved.insert");
|
||||
TestSupport.insert(DataAnalysisTables.SAVED, TestSupport.saved("alice", false));
|
||||
QRecord denied = new InsertAction().execute(new InsertInput(DataAnalysisTables.SAVED).withRecords(List.of(TestSupport.saved("bob", false)))).getRecords().getFirst();
|
||||
assertThat(denied.getErrors()).isNotEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query with explicit enforcement, as the home and future API must do.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table) throws Exception
|
||||
{
|
||||
QueryInput input = new QueryInput(table);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
/*
|
||||
* 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.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.ColumnProfiler;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.StandInAnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
|
||||
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesRequest;
|
||||
import com.kingsrook.qbits.dataanalysis.result.Drill;
|
||||
import com.kingsrook.qbits.dataanalysis.result.DuplicatesRequest;
|
||||
import com.kingsrook.qbits.dataanalysis.result.Metric;
|
||||
import com.kingsrook.qbits.dataanalysis.result.RunStatus;
|
||||
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
|
||||
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.exceptions.QPermissionDeniedException;
|
||||
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.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.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared backend acceptance tests, including a generic drill equivalence walker.
|
||||
*******************************************************************************/
|
||||
abstract class EngineContractTest extends EngineFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Every emitted key across all profile kinds has a label, on every backend.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void presentationAcrossProfileKinds() throws Exception
|
||||
{
|
||||
java.util.Set<String> keys = new java.util.HashSet<>();
|
||||
for(String field : List.of("amount", "quantity", "note", "paid", "order_date", "created"))
|
||||
{
|
||||
ColumnProfile profile = service.columnProfile(column(field));
|
||||
java.util.List<Metric> metrics = new java.util.ArrayList<>(profile.getCounts());
|
||||
if(profile.getNumeric() != null)
|
||||
{
|
||||
metrics.addAll(profile.getNumeric().getMetrics());
|
||||
}
|
||||
if(profile.getText() != null)
|
||||
{
|
||||
metrics.addAll(profile.getText().getMetrics());
|
||||
}
|
||||
if(profile.getDates() != null)
|
||||
{
|
||||
metrics.addAll(profile.getDates().getMetrics());
|
||||
assertThat(profile.getChart().getX().getLabel()).endsWith(" (day)");
|
||||
assertThat(profile.getChart().getSeries().getFirst().getPoints().getFirst().getX().toString()).isEqualTo("2026-01-01");
|
||||
}
|
||||
if(profile.getBooleans() != null)
|
||||
{
|
||||
metrics.addAll(profile.getBooleans());
|
||||
}
|
||||
for(Metric metric : metrics)
|
||||
{
|
||||
keys.add(metric.getKey());
|
||||
assertThat(metric.getLabel()).isEqualTo(com.kingsrook.qbits.dataanalysis.engine.MetricLabels.label(metric.getKey(), metric.getValueType()));
|
||||
assertThat(metric.getFormatted()).isNotBlank();
|
||||
}
|
||||
}
|
||||
assertThat(keys).containsExactlyInAnyOrder("rows", "nonNull", "nulls", "blanks", "distinct", "uniqueness", "min", "max", "mean", "sum", "stddev",
|
||||
"p1", "p5", "p25", "p50", "p75", "p95", "p99", "zeros", "negatives", "outliers", "minLength", "maxLength", "meanLength", "true", "false", "range");
|
||||
ColumnProfile amount = service.columnProfile(column("amount"));
|
||||
assertThat(metric(amount.getNumeric().getMetrics(), "sum").getFormatted()).isEqualTo("$1,170.00");
|
||||
assertThat(amount.getNumeric().getHistogram()).allSatisfy(v -> assertThat(v.getLabel()).contains("$"));
|
||||
Metric uniqueness = metric(amount.getCounts(), "uniqueness");
|
||||
assertThat(uniqueness.getValueType().name()).isEqualTo("PERCENT");
|
||||
assertThat(uniqueness.getValue().toString()).isEqualTo("0.727273");
|
||||
assertThat(uniqueness.getFormatted()).isEqualTo("72.7%");
|
||||
assertThat(metric(service.columnProfile(column("created")).getDates().getMetrics(), "min").getFormatted()).isEqualTo("2026-01-01 06:00");
|
||||
assertThat(service.columnProfile(column("note")).getWarnings()).isEmpty();
|
||||
var table = service.tableProfile(new TableProfileRequest().withTableName("orders").withFields(List.of("note")));
|
||||
assertThat(table.getRowCount().getLabel()).isEqualTo("Rows");
|
||||
assertThat(table.getFields().getFirst().getNulls().getLabel()).isEqualTo("Nulls");
|
||||
assertThat(table.getFields().getFirst().getBlanks().getLabel()).isEqualTo("Blanks");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Decimal moments retain ten significant digits; drills retain exact membership.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void numericProfile() throws Exception
|
||||
{
|
||||
ColumnProfile result = service.columnProfile(column("amount"));
|
||||
assertMetric(result.getCounts(), "rows", "12");
|
||||
assertMetric(result.getCounts(), "nonNull", "11");
|
||||
assertMetric(result.getCounts(), "nulls", "1");
|
||||
assertMetric(result.getCounts(), "distinct", "8");
|
||||
Map<String, String> expected = Map.ofEntries(Map.entry("min", "-10"), Map.entry("max", "1000"), Map.entry("sum", "1170"),
|
||||
Map.entry("mean", "106.36363636363636"), Map.entry("p1", "-9"), Map.entry("p5", "-5"), Map.entry("p25", "5"),
|
||||
Map.entry("p50", "20"), Map.entry("p75", "35"), Map.entry("p95", "525"), Map.entry("p99", "905"),
|
||||
Map.entry("zeros", "2"), Map.entry("negatives", "1"), Map.entry("outliers", "1"));
|
||||
for(var entry : expected.entrySet())
|
||||
{
|
||||
assertMetric(result.getNumeric().getMetrics(), entry.getKey(), new BigDecimal(entry.getValue()).round(new java.math.MathContext(10)).toPlainString());
|
||||
}
|
||||
double[] values = {-10, 0, 0, 10, 10, 20, 20, 30, 40, 50, 1000};
|
||||
double mean = 1170.0 / 11;
|
||||
double variance = java.util.Arrays.stream(values).map(v -> Math.pow(v - mean, 2)).sum() / 10;
|
||||
assertMetric(result.getNumeric().getMetrics(), "stddev", BigDecimal.valueOf(Math.sqrt(variance)).round(new java.math.MathContext(10)).toPlainString());
|
||||
assertThat(result.getNumeric().getHistogram()).hasSize(22);
|
||||
assertThat(result.getNumeric().getHistogram().stream().mapToLong(v -> v.getCount()).sum()).isEqualTo(11);
|
||||
assertThat(result.getNumeric().getOutliers()).singleElement().satisfies(v -> assertThat(new BigDecimal(v.getValue().toString())).isEqualByComparingTo("1000"));
|
||||
assertThat(result.getChart().getKind().name()).isEqualTo("HISTOGRAM");
|
||||
assertDrills(result);
|
||||
assertDrills(service.columnProfile(column("quantity")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Text nulls, whitespace, lengths, shapes, enum labels and FK labels.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void textAndLabels() throws Exception
|
||||
{
|
||||
ColumnProfile text = service.columnProfile(column("note"));
|
||||
assertMetric(text.getCounts(), "nulls", "1");
|
||||
assertMetric(text.getCounts(), "blanks", "4");
|
||||
assertMetric(text.getCounts(), "distinct", "10");
|
||||
assertMetric(text.getText().getMetrics(), "minLength", "0");
|
||||
assertMetric(text.getText().getMetrics(), "maxLength", "5");
|
||||
assertMetric(text.getText().getMetrics(), "meanLength", Double.toString(28.0 / 11));
|
||||
assertThat(text.getText().getPatterns()).anySatisfy(v ->
|
||||
{
|
||||
assertThat(v.getLabel()).isEqualTo("A-9");
|
||||
assertThat(v.getCount()).isEqualTo(3);
|
||||
});
|
||||
assertDrills(text);
|
||||
ColumnProfile customer = service.columnProfile(column("customer"));
|
||||
assertThat(customer.getTopValues()).extracting(v -> v.getLabel()).containsExactly("Alice Co", "Bob Co");
|
||||
assertThat(customer.getChart().getKind().name()).isEqualTo("BAR");
|
||||
assertDrills(customer);
|
||||
ColumnProfile status = service.columnProfile(column("status"));
|
||||
assertThat(status.getTopValues()).extracting(v -> v.getLabel()).containsExactlyInAnyOrder("New order", "Paid order");
|
||||
assertDrills(status);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Date gaps, timezone buckets, moving average and boolean counts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void datesAndBooleans() throws Exception
|
||||
{
|
||||
ColumnProfile date = service.columnProfile(column("order_date"));
|
||||
assertThat(date.getDates().getUnit()).isEqualTo("DAY");
|
||||
assertThat(date.getDates().getBuckets()).extracting(v -> v.getCount()).containsExactly(4L, 0L, 8L);
|
||||
assertThat(date.getDates().getGaps()).hasSize(1);
|
||||
assertThat(date.getChart().getSeries()).hasSize(2);
|
||||
assertMetric(date.getDates().getMetrics(), "range", "2");
|
||||
assertDrills(date);
|
||||
ColumnProfile instant = service.columnProfile(column("created"));
|
||||
assertThat(instant.getDates().getHourOfDay()).extracting(v -> v.getValue().toString()).containsExactlyInAnyOrder("6", "12");
|
||||
assertDrills(instant);
|
||||
ColumnProfile bool = service.columnProfile(column("paid"));
|
||||
assertMetric(bool.getBooleans(), "true", "6");
|
||||
assertMetric(bool.getBooleans(), "false", "5");
|
||||
assertMetric(bool.getBooleans(), "nulls", "1");
|
||||
assertDrills(bool);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Completeness, field and pair keys and duplicate tuples.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableAndDuplicates() throws Exception
|
||||
{
|
||||
var table = service.tableProfile(new TableProfileRequest().withTableName("orders"));
|
||||
assertThat(table.getFields()).noneMatch(f -> f.getField().equals("secret"));
|
||||
assertThat(table.getFields()).anySatisfy(f ->
|
||||
{
|
||||
assertThat(f.getField()).isEqualTo("note");
|
||||
assertThat(f.getCompleteness().doubleValue()).isEqualTo(0.666667);
|
||||
});
|
||||
assertThat(table.getCandidateKeys()).anyMatch(k -> k.getFields().equals(List.of("id")));
|
||||
var pairs = service.tableProfile(new TableProfileRequest().withTableName("orders").withFields(List.of("status", "quantity")));
|
||||
assertThat(pairs.getCandidateKeys()).anyMatch(k -> Set.copyOf(k.getFields()).equals(Set.of("status", "quantity")));
|
||||
assertDrills(table);
|
||||
assertDrills(pairs);
|
||||
var duplicates = service.duplicates(new DuplicatesRequest().withTableName("orders").withFields(List.of("amount")));
|
||||
assertThat(duplicates.getGroups()).extracting(v -> v.getCount()).containsExactly(2L, 2L, 2L);
|
||||
assertDrills(duplicates);
|
||||
assertDrills(service.duplicates(new DuplicatesRequest().withTableName("orders").withFields(List.of("status", "customer"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Paging and search retain exact memberships and enforce page size.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void distinctPages() throws Exception
|
||||
{
|
||||
var first = service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("note").withPageSize(2));
|
||||
var second = service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("note").withPageSize(2).withPage(1));
|
||||
assertThat(first.getHasMore()).isTrue();
|
||||
assertThat(first.getValues()).hasSize(2);
|
||||
assertThat(second.getValues()).hasSize(2);
|
||||
assertThat(first.getValues().getFirst().getValue()).isNotEqualTo(second.getValues().getFirst().getValue());
|
||||
assertDrills(first);
|
||||
assertDrills(second);
|
||||
var search = service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("note").withSearch("AA"));
|
||||
assertThat(search.getValues()).hasSize(1);
|
||||
assertDrills(search);
|
||||
assertThatThrownBy(() -> service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("note").withPageSize(501))).isInstanceOf(QException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing READ/analyze, locked fields and exclusions fail before computation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissionChecks() throws Exception
|
||||
{
|
||||
assertThat(service.capabilities("orders").getFields()).doesNotContain("secret");
|
||||
assertThatThrownBy(() -> service.columnProfile(column("secret"))).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.init(instance, TestSupport.user("alice").withPermission("orders.analyze"));
|
||||
assertThatThrownBy(() -> service.columnProfile(column("amount"))).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.init(instance, TestSupport.user("alice").withPermission("orders.read"));
|
||||
assertThatThrownBy(() -> service.columnProfile(column("amount"))).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.init(instance, session());
|
||||
config.withTableDefaults(Map.of("orders", new TableAnalysisDefaults().withExcludedFields(Set.of("amount"))));
|
||||
service.getConfigResolver().invalidate();
|
||||
assertThat(service.capabilities("orders").getFields()).doesNotContain("amount");
|
||||
assertThatThrownBy(() -> service.columnProfile(column("amount"))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> service.capabilities("missing")).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record locks constrain every backend primitive and every returned drill.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recordLocks() throws Exception
|
||||
{
|
||||
QSession alice = TestSupport.user("alice").withPermissions("orders.read", "orders.analyze", "customer.read",
|
||||
"data_analysis.run.read", "data_analysis.run.insert", "data_analysis.run.edit").withSecurityKeyValue("customer_scope", 1);
|
||||
QContext.init(instance, alice);
|
||||
var result = service.columnProfile(column("amount"));
|
||||
assertMetric(result.getCounts(), "rows", "8");
|
||||
assertMetric(result.getNumeric().getMetrics(), "sum", "120");
|
||||
assertDrills(result);
|
||||
assertDrills(service.tableProfile(new TableProfileRequest().withTableName("orders")));
|
||||
assertDrills(service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("note")));
|
||||
assertDrills(service.duplicates(new DuplicatesRequest().withTableName("orders").withFields(List.of("quantity"))));
|
||||
QContext.getQSession().withSecurityKeyValue("customer_scope", 2);
|
||||
assertMetric(service.columnProfile(column("amount")).getCounts(), "rows", "12");
|
||||
}
|
||||
/*******************************************************************************
|
||||
** Guarded cache JSON contains no unmerged rare top values or extreme listings.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void privacyGuard() throws Exception
|
||||
{
|
||||
config.withDefaultMinGroupSize(3);
|
||||
service.getConfigResolver().invalidate();
|
||||
ColumnProfile note = service.columnProfile(column("note"));
|
||||
assertThat(note.getTopValues()).singleElement().satisfies(v ->
|
||||
{
|
||||
assertThat(v.getLabel()).startsWith("Other (");
|
||||
assertThat(v.getCount()).isEqualTo(12);
|
||||
assertThat(v.getDrill()).isNull();
|
||||
assertThat(v.getValue()).isNull();
|
||||
});
|
||||
ColumnProfile amount = service.columnProfile(column("amount"));
|
||||
assertThat(amount.getNumeric().getOutliers()).isEmpty();
|
||||
assertThat(metric(amount.getNumeric().getMetrics(), "outliers").getDrill()).isNull();
|
||||
var tiny = service.columnProfile(column("amount").withFilter(DrillFilters.condition("id", QCriteriaOperator.EQUALS, 1)));
|
||||
for(String key : List.of("min", "max", "mean", "stddev", "p1", "p50", "p99"))
|
||||
{
|
||||
assertThat(metric(tiny.getNumeric().getMetrics(), key).getSuppressed()).isTrue();
|
||||
assertThat(metric(tiny.getNumeric().getMetrics(), key).getValue()).isNull();
|
||||
assertThat(metric(tiny.getNumeric().getMetrics(), key).getSuppressedReason()).isEqualTo("Hidden: fewer than 3 records");
|
||||
}
|
||||
var page = service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("note").withPageSize(1));
|
||||
assertThat(page.getValues()).hasSize(1);
|
||||
assertThat(page.getValues().getFirst().getCount()).isEqualTo(12);
|
||||
assertThat(page.getHasMore()).isFalse();
|
||||
for(QRecord run : runs())
|
||||
{
|
||||
JsonNode json = new ObjectMapper().readTree(run.getValueString("result_json"));
|
||||
for(JsonNode group : json.path("topValues"))
|
||||
{
|
||||
if(group.path("count").asLong() < 3)
|
||||
{
|
||||
assertThat(group.path("drill").isMissingNode() || group.path("drill").isNull()).isTrue();
|
||||
assertThat(group.path("value").isMissingNode() || group.path("value").isNull()).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real asynchronous context propagation, completion, cache hit and invalidation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void estimatesRunsAndCache() throws Exception
|
||||
{
|
||||
config.withDefaultSampleThresholdRows(5).withDefaultSampleRows(4);
|
||||
service = newService(config, java.util.concurrent.ForkJoinPool.commonPool());
|
||||
ColumnProfile estimate = service.columnProfile(column("amount"));
|
||||
assertThat(estimate.getEstimate()).isTrue();
|
||||
assertMetric(estimate.getCounts(), "rows", "12");
|
||||
assertThat(metric(estimate.getCounts(), "rows").getEstimate()).isFalse();
|
||||
assertThat(metric(estimate.getCounts(), "nonNull").getEstimate()).isTrue();
|
||||
assertThat(metric(estimate.getCounts(), "nonNull").getFormatted()).startsWith("≈ ");
|
||||
assertDrills(estimate);
|
||||
assertThat(count(metric(estimate.getCounts(), "rows").getDrill())).isEqualTo(12);
|
||||
if(service.backend("orders") instanceof com.kingsrook.qbits.dataanalysis.engine.CoreAnalysisBackend)
|
||||
{
|
||||
assertThat(estimate.getWarnings()).anyMatch(w -> w.contains("random sample") && w.contains("scaled to the full 12 rows"));
|
||||
}
|
||||
else
|
||||
{
|
||||
assertThat(estimate.getSampleRows()).isEqualTo(4);
|
||||
assertMetric(estimate.getCounts(), "nonNull", "4");
|
||||
assertMetric(estimate.getNumeric().getMetrics(), "sum", "10");
|
||||
assertThat(estimate.getWarnings()).contains("Estimate from the first 4 rows by id");
|
||||
}
|
||||
String id = service.startExactRun(column("amount"));
|
||||
RunStatus run = awaitRun(id);
|
||||
assertThat(run.getStatus()).isEqualTo("COMPLETE");
|
||||
assertMetric(run.getColumnProfile().getCounts(), "rows", "12");
|
||||
int before = runs().size();
|
||||
assertThat(service.startExactRun(column("amount"))).isEqualTo(id);
|
||||
assertMetric(service.columnProfile(column("amount").withExact(true)).getCounts(), "rows", "12");
|
||||
assertThat(runs()).hasSize(before);
|
||||
TestSupport.insert("orders", new QRecord().withValue("id", 13).withValue("customer", 1).withValue("amount", BigDecimal.ONE));
|
||||
String next = service.startExactRun(column("amount"));
|
||||
assertThat(next).isNotEqualTo(id);
|
||||
assertThat(awaitRun(next).getStatus()).isEqualTo("COMPLETE");
|
||||
assertMetric(service.getRun(next).getColumnProfile().getCounts(), "rows", "13");
|
||||
assertThat(service.tableProfile(new TableProfileRequest().withTableName("orders")).getEstimate()).isTrue();
|
||||
String table = service.startExactRun(new TableProfileRequest().withTableName("orders").withFields(List.of("id", "amount")));
|
||||
assertThat(awaitRun(table).getTableProfile().getRowCount().getValue().toString()).isEqualTo("13");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Partial results retain exact prefix drills; empty populations are valid.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void truncatedAndEmpty() throws Exception
|
||||
{
|
||||
config.withDefaultMaxRows(3);
|
||||
service.getConfigResolver().invalidate();
|
||||
ColumnProfile partial = service.columnProfile(column("amount").withExact(true));
|
||||
assertThat(partial.getTruncated()).isTrue();
|
||||
assertThat(partial.getRowsScanned()).isEqualTo(3);
|
||||
assertMetric(partial.getCounts(), "rows", "3");
|
||||
assertDrills(partial);
|
||||
var emptyFilter = DrillFilters.condition("id", QCriteriaOperator.EQUALS, -1);
|
||||
var empty = service.columnProfile(column("amount").withFilter(emptyFilter));
|
||||
assertMetric(empty.getCounts(), "rows", "0");
|
||||
assertThat(empty.getNumeric().getHistogram()).isEmpty();
|
||||
assertDrills(empty);
|
||||
assertDrills(service.columnProfile(column("created").withFilter(emptyFilter)));
|
||||
assertDrills(service.columnProfile(column("note").withFilter(emptyFilter)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Drill composition never modifies the request's nested OR filter.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void originalFilterUnchanged() throws Exception
|
||||
{
|
||||
QQueryFilter filter = DrillFilters.condition("id", QCriteriaOperator.EQUALS, 1).withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("id", QCriteriaOperator.EQUALS, 12);
|
||||
String original = JsonUtils.toJson(filter);
|
||||
ColumnProfile result = service.columnProfile(column("amount").withFilter(filter));
|
||||
assertMetric(result.getCounts(), "rows", "2");
|
||||
assertDrills(result);
|
||||
assertThat(JsonUtils.toJson(filter)).isEqualTo(original);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Config rows override defaults and restore the session after privileged reads.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configResolution() throws Exception
|
||||
{
|
||||
config.withDefaultMaxRows(500).withTableDefaults(Map.of("orders", new TableAnalysisDefaults().withMaxRows(100).withSampleRows(8).withMinGroupSize(2)));
|
||||
service.getConfigResolver().invalidate();
|
||||
assertThat(service.capabilities("orders").getConfig().getMaxRows()).isEqualTo(100);
|
||||
TestSupport.insert(DataAnalysisTables.TABLE_CONFIG, new QRecord().withValue("table_name", "orders").withValue("max_rows", 50)
|
||||
.withValue("min_group_size", 3).withValue("analyzable_fields", "amount, note").withValue("excluded_fields", "note").withValue("cache_ttl_minutes", 2));
|
||||
assertThat(service.capabilities("orders").getConfig().getMaxRows()).isEqualTo(100);
|
||||
service.getConfigResolver().invalidate();
|
||||
QSession current = QContext.getQSession();
|
||||
var capabilities = service.capabilities("orders");
|
||||
assertThat(capabilities.getConfig().getMaxRows()).isEqualTo(50);
|
||||
assertThat(capabilities.getConfig().getSampleRows()).isEqualTo(8);
|
||||
assertThat(capabilities.getConfig().getMinGroupSize()).isEqualTo(3);
|
||||
assertThat(capabilities.getConfig().getCacheTtlMinutes()).isEqualTo(2);
|
||||
assertThat(capabilities.getFields()).containsExactly("amount");
|
||||
assertThat(QContext.getQSession()).isSameAs(current);
|
||||
config.withExcludeTables(Set.of("orders"));
|
||||
service.getConfigResolver().invalidate();
|
||||
assertThatThrownBy(() -> service.capabilities("orders")).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Changed privacy policy and another user cannot retrieve older cached data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cachePolicyRechecked() throws Exception
|
||||
{
|
||||
String id = service.startExactRun(column("amount"));
|
||||
config.withDefaultMinGroupSize(3);
|
||||
service.getConfigResolver().invalidate();
|
||||
assertThatThrownBy(() -> service.getRun(id)).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(service.startExactRun(column("amount"))).isNotEqualTo(id);
|
||||
QContext.init(instance, TestSupport.user("bob").withPermissions("data_analysis.run.read", "orders.read", "orders.analyze"));
|
||||
assertThatThrownBy(() -> service.getRun(id)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Calendar boundaries, weekly/monthly/yearly drills and interpolation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void shapeAndDateRules() throws Exception
|
||||
{
|
||||
assertThat(StandInAnalysisBackend.shape("AAA-999 QQ_88!!")).isEqualTo("A-9 A_9!!");
|
||||
LocalDate first = LocalDate.of(2024, 1, 1);
|
||||
assertThat(ColumnProfiler.autoBucket(first, first.plusDays(92))).isEqualTo(AnalysisBackend.DateUnit.DAY);
|
||||
assertThat(ColumnProfiler.autoBucket(first, first.plusDays(93))).isEqualTo(AnalysisBackend.DateUnit.WEEK);
|
||||
assertThat(ColumnProfiler.autoBucket(first, first.plusYears(2))).isEqualTo(AnalysisBackend.DateUnit.WEEK);
|
||||
assertThat(ColumnProfiler.autoBucket(first, first.plusYears(2).plusDays(1))).isEqualTo(AnalysisBackend.DateUnit.MONTH);
|
||||
assertThat(ColumnProfiler.autoBucket(first, first.plusYears(10))).isEqualTo(AnalysisBackend.DateUnit.MONTH);
|
||||
assertThat(ColumnProfiler.autoBucket(first, first.plusYears(10).plusDays(1))).isEqualTo(AnalysisBackend.DateUnit.YEAR);
|
||||
for(AnalysisBackend.DateUnit unit : List.of(AnalysisBackend.DateUnit.WEEK, AnalysisBackend.DateUnit.MONTH, AnalysisBackend.DateUnit.QUARTER, AnalysisBackend.DateUnit.YEAR))
|
||||
{
|
||||
var buckets = service.backend("orders").dateBuckets("orders", null, "order_date", unit, java.time.ZoneId.of("America/Chicago"));
|
||||
assertThat(buckets.stream().mapToLong(AnalysisBackend.GroupCount::count).sum()).isEqualTo(12);
|
||||
for(var bucket : buckets)
|
||||
{
|
||||
assertThat(count(new Drill().withTableName("orders").withFilter(bucket.condition()))).isEqualTo(bucket.count());
|
||||
}
|
||||
}
|
||||
assertThat(StandInAnalysisBackend.percentile(List.of(BigDecimal.ZERO, BigDecimal.TEN), new BigDecimal("0.25"))).isEqualByComparingTo("2.5");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Common request builder.
|
||||
*******************************************************************************/
|
||||
protected static ColumnProfileRequest column(String field)
|
||||
{
|
||||
return (new ColumnProfileRequest().withTableName("orders").withFieldName(field));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Select metrics by their stable semantic keys.
|
||||
*******************************************************************************/
|
||||
protected static Metric metric(List<Metric> metrics, String key)
|
||||
{
|
||||
return (metrics.stream().filter(m -> m.getKey().equals(key)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Scalar reference tolerance.
|
||||
*******************************************************************************/
|
||||
protected static void assertMetric(List<Metric> metrics, String key, String expected)
|
||||
{
|
||||
assertThat(new BigDecimal(metric(metrics, key).getValue().toString()).doubleValue()).as(key).isCloseTo(Double.parseDouble(expected), within(1e-9));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Walk actual serialized payloads so cached drills receive the same assertions.
|
||||
*******************************************************************************/
|
||||
protected void assertDrills(Object result) throws Exception
|
||||
{
|
||||
walk(new ObjectMapper().readTree(JsonUtils.toJson(result)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Collect count metrics, group counts, chart points and candidate-key counts.
|
||||
*******************************************************************************/
|
||||
private void walk(JsonNode node) throws Exception
|
||||
{
|
||||
if(node.path("estimate").asBoolean(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(node.isObject())
|
||||
{
|
||||
if(node.hasNonNull("drill"))
|
||||
{
|
||||
JsonNode number = node.has("count") ? node.get("count") : node.has("rowCount") ? node.get("rowCount") : node.has("y") ? node.get("y") : node.get("value");
|
||||
assertThat(number).as(node.toString()).isNotNull();
|
||||
Drill drill = JsonUtils.toObject(node.get("drill").toString(), Drill.class);
|
||||
assertThat(count(drill)).as(node.toString()).isEqualTo(number.asLong());
|
||||
}
|
||||
var fields = node.fields();
|
||||
while(fields.hasNext())
|
||||
{
|
||||
var field = fields.next();
|
||||
if(!field.getKey().equals("drill") && !field.getKey().equals("filter"))
|
||||
{
|
||||
walk(field.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(node.isArray())
|
||||
{
|
||||
for(JsonNode child : node)
|
||||
{
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use core count under the current user's locks.
|
||||
*******************************************************************************/
|
||||
private static long count(Drill drill) throws Exception
|
||||
{
|
||||
CountInput input = new CountInput(drill.getTableName()).withFilter(drill.getFilter());
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new CountAction().execute(input).getCount());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect persistent cache rows through enforced actions.
|
||||
*******************************************************************************/
|
||||
protected List<QRecord> runs() throws Exception
|
||||
{
|
||||
QueryInput input = new QueryInput(DataAnalysisTables.RUN).withShouldFetchHeavyFields(true);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Wait for actual asynchronous completion with a fixed deadline.
|
||||
*******************************************************************************/
|
||||
protected RunStatus awaitRun(String id) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + 20_000_000_000L;
|
||||
RunStatus run;
|
||||
do
|
||||
{
|
||||
run = service.getRun(id);
|
||||
if(!"RUNNING".equals(run.getStatus()))
|
||||
{
|
||||
return (run);
|
||||
}
|
||||
Thread.sleep(10);
|
||||
}
|
||||
while(System.nanoTime() < deadline);
|
||||
throw new AssertionError("Run did not finish: " + id);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Related labels obey READ, field locks and record locks without bypasses.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void relatedLabelSecurity() throws Exception
|
||||
{
|
||||
String original = service.startExactRun(column("customer"));
|
||||
QContext.getQSession().getPermissions().remove("customer.read");
|
||||
assertThat(service.columnProfile(column("customer")).getTopValues()).extracting(v -> v.getLabel()).containsExactly("1", "2");
|
||||
QContext.getQSession().withPermission("customer.read");
|
||||
instance.getTable("customer").getField("name").withFieldSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock()
|
||||
.withSecurityKeyType("secret_access").withDefaultBehavior(com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock.Behavior.DENY));
|
||||
assertThatThrownBy(() -> service.getRun(original)).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(service.columnProfile(column("customer")).getTopValues()).extracting(v -> v.getLabel()).containsExactly("1", "2");
|
||||
instance.getTable("customer").getField("name").withFieldSecurityLock(null);
|
||||
instance.getTable("customer").withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock()
|
||||
.withSecurityKeyType("customer_scope").withFieldName("id"));
|
||||
QContext.getQSession().getSecurityKeyValues().remove("orders.all");
|
||||
QContext.getQSession().withSecurityKeyValue("customer_scope", 1);
|
||||
assertThat(service.columnProfile(column("customer")).getTopValues()).extracting(v -> v.getLabel()).containsExactly("Alice Co");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Zero/constant populations, zero limits, rejected executors and disabled cache.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundaryAndFailurePaths() throws Exception
|
||||
{
|
||||
assertThat(DataAnalysisRuntime.service()).isNotNull();
|
||||
var constant = service.columnProfile(column("amount").withFilter(DrillFilters.condition("id", QCriteriaOperator.EQUALS, 2)));
|
||||
assertThat(constant.getNumeric().getHistogram().stream().mapToLong(v -> v.getCount()).sum()).isEqualTo(1);
|
||||
assertDrills(constant);
|
||||
config.withDefaultMaxRows(0).withDefaultCacheTtlMinutes(0);
|
||||
service.getConfigResolver().invalidate();
|
||||
assertMetric(service.columnProfile(column("amount")).getCounts(), "rows", "0");
|
||||
int previous = runs().size();
|
||||
service.columnProfile(column("amount"));
|
||||
assertThat(runs()).hasSize(previous + 1);
|
||||
service = newService(config, task ->
|
||||
{
|
||||
throw new java.util.concurrent.RejectedExecutionException("test");
|
||||
});
|
||||
assertThatThrownBy(() -> service.startExactRun(column("amount"))).isInstanceOf(QException.class);
|
||||
assertThat(runs()).anyMatch(r -> "FAILED".equals(r.getValueString("status")));
|
||||
assertThatThrownBy(() -> service.getRun("bad")).isInstanceOf(QException.class);
|
||||
assertThatThrownBy(() -> service.duplicates(new DuplicatesRequest().withTableName("orders"))).isInstanceOf(QException.class);
|
||||
assertThatThrownBy(() -> service.columnProfile(null)).isInstanceOf(QException.class);
|
||||
assertThatThrownBy(() -> service.startExactRun(new DistinctValuesRequest().withTableName("orders").withFieldName("note"))).isInstanceOf(QException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Decimal scales, integral boundaries and final-bin closure must be portable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scaledDecimalsAndIntegerBins() throws Exception
|
||||
{
|
||||
TestSupport.insert("orders", new QRecord().withValue("id", 13).withValue("customer", 1).withValue("amount", new BigDecimal("0.00")));
|
||||
TestSupport.insert("orders", new QRecord().withValue("id", 14).withValue("customer", 1).withValue("amount", new BigDecimal("10.000")));
|
||||
var result = service.columnProfile(column("amount"));
|
||||
assertMetric(result.getCounts(), "distinct", "8");
|
||||
assertMetric(result.getNumeric().getMetrics(), "zeros", "3");
|
||||
assertThat(result.getNumeric().getHistogram().stream().mapToLong(v -> v.getCount()).sum()).isEqualTo(13);
|
||||
assertDrills(result);
|
||||
var quantity = service.columnProfile(column("quantity"));
|
||||
assertMetric(quantity.getNumeric().getMetrics(), "mean", "3.5");
|
||||
assertThat(quantity.getNumeric().getHistogram().stream().mapToLong(v -> v.getCount()).sum()).isEqualTo(12);
|
||||
assertDrills(quantity);
|
||||
var filtered = column("amount").withFilter(DrillFilters.condition("amount", QCriteriaOperator.EQUALS, new BigDecimal("0.00")));
|
||||
var selected = service.columnProfile(filtered);
|
||||
assertThat(((Number) metric(selected.getCounts(), "rows").getValue()).longValue()).isEqualTo(count(new Drill().withTableName("orders").withFilter(filtered.getFilter())));
|
||||
assertDrills(selected);
|
||||
assertDrills(service.columnProfile(filtered));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired cache rows miss; reordered AND criteria share normalized identity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cacheExpiryAndNormalizedFilters() throws Exception
|
||||
{
|
||||
QQueryFilter a = DrillFilters.condition("id", QCriteriaOperator.GREATER_THAN, 1).withCriteria("id", QCriteriaOperator.LESS_THAN, 8);
|
||||
QQueryFilter b = DrillFilters.condition("id", QCriteriaOperator.LESS_THAN, 8).withCriteria("id", QCriteriaOperator.GREATER_THAN, 1);
|
||||
String id = service.startExactRun(column("amount").withFilter(a));
|
||||
assertThat(service.startExactRun(column("amount").withFilter(b))).isEqualTo(id);
|
||||
var update = new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(DataAnalysisTables.RUN)
|
||||
.withRecords(List.of(new QRecord().withValue("id", Integer.valueOf(id)).withValue("finished_at", java.time.Instant.now().minusSeconds(7200))));
|
||||
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
new com.kingsrook.qqq.backend.core.actions.tables.UpdateAction().execute(update);
|
||||
assertThat(service.startExactRun(column("amount").withFilter(a))).isNotEqualTo(id);
|
||||
var backend = service.backend("customer");
|
||||
assertThat(com.kingsrook.qbits.dataanalysis.run.DataVersion.of(backend, "customer", null)).isEqualTo("2:id:2");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Local midnight date drills span 23 hours across daylight-saving transitions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void daylightSavingAndSearch() throws Exception
|
||||
{
|
||||
TestSupport.insert("orders", new QRecord().withValue("id", 13).withValue("customer", 1).withValue("created", java.time.Instant.parse("2026-03-08T06:00:00Z")));
|
||||
TestSupport.insert("orders", new QRecord().withValue("id", 14).withValue("customer", 1).withValue("created", java.time.Instant.parse("2026-03-09T04:59:59Z")));
|
||||
TestSupport.insert("orders", new QRecord().withValue("id", 15).withValue("customer", 1).withValue("created", java.time.Instant.parse("2026-03-09T05:00:00Z")));
|
||||
var drill = new Drill().withTableName("orders").withFilter(DrillFilters.dateRange("created", LocalDate.of(2026, 3, 8), LocalDate.of(2026, 3, 9), true, java.time.ZoneId.of("America/Chicago")));
|
||||
assertThat(count(drill)).isEqualTo(2);
|
||||
assertDrills(service.columnProfile(column("created").withFilter(DrillFilters.condition("id", QCriteriaOperator.GREATER_THAN_OR_EQUALS, 13))));
|
||||
var values = service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("amount").withSearch("1000"));
|
||||
assertThat(values.getValues()).hasSize(1);
|
||||
assertThat(values.getTotalDistinct()).isEqualTo(1);
|
||||
assertDrills(values);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Computation failures persist a safe FAILED row, with no partial result JSON.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void executionFailureAndGuardedBooleans() throws Exception
|
||||
{
|
||||
List<Runnable> jobs = new java.util.ArrayList<>();
|
||||
service = newService(config, jobs::add);
|
||||
String id = service.startExactRun(column("amount"));
|
||||
var table = instance.getTables().remove("orders");
|
||||
jobs.getFirst().run();
|
||||
instance.addTable(table);
|
||||
assertThat(service.getRun(id).getStatus()).isEqualTo("FAILED");
|
||||
assertThat(service.getRun(id).getError()).contains("could not be completed");
|
||||
assertThat(runs().getFirst().getValue("result_json")).isNull();
|
||||
config.withDefaultMinGroupSize(3);
|
||||
service = newService(config, Runnable::run);
|
||||
var paid = service.columnProfile(column("paid"));
|
||||
assertThat(paid.getBooleans()).anySatisfy(m ->
|
||||
{
|
||||
assertThat(m.getKey()).isEqualTo("other");
|
||||
assertThat(m.getDrill()).isNull();
|
||||
});
|
||||
assertDrills(paid);
|
||||
assertThat(service.columnProfile(column("created")).getDates().getGaps()).isEmpty();
|
||||
assertThat(service.duplicates(new DuplicatesRequest().withTableName("orders").withFields(List.of("quantity"))).getGroups())
|
||||
.allMatch(v -> v.getDrill() == null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
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.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.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.FieldSecurityLock;
|
||||
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.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
/*******************************************************************************
|
||||
** Identical fixture built through core actions on all three supported test backends.
|
||||
*******************************************************************************/
|
||||
abstract class EngineFixture
|
||||
{
|
||||
protected DataAnalysisService service;
|
||||
protected DataAnalysisQBitConfig config;
|
||||
protected QInstance instance;
|
||||
protected Connection connection;
|
||||
|
||||
/*******************************************************************************
|
||||
** Null selects memory; subclasses supply real database metadata.
|
||||
*******************************************************************************/
|
||||
protected RDBMSBackendMetaData database()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build metadata and insert rows without direct memory-store injection.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
instance = TestSupport.instance();
|
||||
RDBMSBackendMetaData database = database();
|
||||
String backend = database == null ? "memory" : "database_" + java.util.UUID.randomUUID();
|
||||
if(database != null)
|
||||
{
|
||||
instance.addBackend(database.withName(backend));
|
||||
connection = DriverManager.getConnection(database.getJdbcUrl(), database.getUsername(), database.getPassword());
|
||||
try(var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP TABLE IF EXISTS orders");
|
||||
statement.execute("DROP TABLE IF EXISTS customer");
|
||||
statement.execute("CREATE TABLE customer (id INTEGER PRIMARY KEY, name VARCHAR(100))");
|
||||
statement.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer INTEGER, status VARCHAR(20), amount DECIMAL(20,4), quantity INTEGER, order_date DATE, created TIMESTAMP, note VARCHAR(100), paid BOOLEAN, modify_date TIMESTAMP, secret VARCHAR(100))");
|
||||
}
|
||||
}
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("customer_scope").withAllAccessKeyName("orders.all"));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("secret_access"));
|
||||
instance.addTable(new QTableMetaData().withName("customer").withBackendName(backend).withPrimaryKeyField("id")
|
||||
.withPermissionRules(DataAnalysisTables.permissionRules("customer")).withRecordLabelFormatAndFields("%s", "name")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("name", QFieldType.STRING)));
|
||||
instance.addPossibleValueSource(QPossibleValueSource.newForTable("customer"));
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("statusValues").withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("NEW", "New order"), new QPossibleValue<>("PAID", "Paid order"))));
|
||||
instance.addTable(new QTableMetaData().withName("orders").withBackendName(backend).withPrimaryKeyField("id")
|
||||
.withPermissionRules(DataAnalysisTables.permissionRules("orders"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("customer", QFieldType.INTEGER).withPossibleValueSourceName("customer"))
|
||||
.withField(new QFieldMetaData("status", QFieldType.STRING).withPossibleValueSourceName("statusValues"))
|
||||
.withField(new QFieldMetaData("amount", QFieldType.DECIMAL).withDisplayFormat(DisplayFormat.CURRENCY)).withField(new QFieldMetaData("quantity", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("order_date", QFieldType.DATE)).withField(new QFieldMetaData("created", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("note", QFieldType.STRING)).withField(new QFieldMetaData("paid", QFieldType.BOOLEAN))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withBackendName("modify_date"))
|
||||
.withField(new QFieldMetaData("secret", QFieldType.STRING).withFieldSecurityLock(new FieldSecurityLock().withSecurityKeyType("secret_access")
|
||||
.withDefaultBehavior(FieldSecurityLock.Behavior.DENY).withOverrideValues(List.of(true))))
|
||||
.withRecordSecurityLock(new RecordSecurityLock().withFieldName("customer").withSecurityKeyType("customer_scope").withNullValueBehavior(RecordSecurityLock.NullValueBehavior.DENY)));
|
||||
new QInstanceEnricher(instance).enrich();
|
||||
QContext.init(instance, session());
|
||||
TestSupport.insert("customer", new QRecord().withValue("id", 1).withValue("name", "Alice Co"));
|
||||
TestSupport.insert("customer", new QRecord().withValue("id", 2).withValue("name", "Bob Co"));
|
||||
Integer[] amounts = {-10, 0, 10, 10, 20, 20, 30, 40, 50, 1000, null, 0};
|
||||
String[] notes = {null, "", " ", "AA-11", "BB-22", "CC-33", "x", "yy", "z", "", "AB12", "CD34"};
|
||||
for(int i = 0; i < amounts.length; i++)
|
||||
{
|
||||
TestSupport.insert("orders", new QRecord().withValue("id", i + 1).withValue("customer", i < 8 ? 1 : 2)
|
||||
.withValue("status", i % 2 == 0 ? "NEW" : "PAID").withValue("amount", amounts[i] == null ? null : BigDecimal.valueOf(amounts[i]))
|
||||
.withValue("quantity", i / 2 + 1).withValue("order_date", LocalDate.of(2026, 1, i < 4 ? 1 : 3))
|
||||
.withValue("created", Instant.parse(i < 4 ? "2026-01-01T12:00:00Z" : "2026-01-03T18:00:00Z"))
|
||||
.withValue("note", notes[i]).withValue("paid", i == 11 ? null : i % 2 == 0).withValue("modifyDate", Instant.parse("2026-10-13T12:00:00Z")));
|
||||
}
|
||||
config = new DataAnalysisQBitConfig().withBackendName("memory");
|
||||
service = newService(config, Runnable::run);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow the identical contract to exercise both primitive implementations.
|
||||
*******************************************************************************/
|
||||
protected DataAnalysisService newService(DataAnalysisQBitConfig configuration, java.util.concurrent.Executor executor)
|
||||
{
|
||||
return (new DataAnalysisService(configuration, executor));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture privileges include an explicit record-lock key.
|
||||
*******************************************************************************/
|
||||
protected QSession session()
|
||||
{
|
||||
return (TestSupport.admin().withPermissions("orders.read", "orders.insert", "orders.edit", "orders.analyze", "customer.read", "customer.insert")
|
||||
.withSecurityKeyValue("orders.all", true).withValue(QSession.VALUE_KEY_USER_TIMEZONE, "America/Chicago"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear context and close SQL connections.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup() throws Exception
|
||||
{
|
||||
QContext.clear();
|
||||
if(connection != null)
|
||||
{
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify native bucket SQL in H2's ordinal-grouping compatibility mode.
|
||||
*******************************************************************************/
|
||||
class H2CompatibilityEngineTest extends H2EngineTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** H2 supports core's GROUP BY positions in PostgreSQL compatibility mode.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected RDBMSBackendMetaData database()
|
||||
{
|
||||
RDBMSBackendMetaData database = super.database();
|
||||
return (database.withJdbcUrl(database.getJdbcUrl() + ";MODE=PostgreSQL"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.UUID;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** H2 executes the identical fixture through the RDBMS module.
|
||||
*******************************************************************************/
|
||||
class H2EngineTest extends EngineContractTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** An isolated database for each test.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected RDBMSBackendMetaData database()
|
||||
{
|
||||
return (new RDBMSBackendMetaData().withVendor("h2").withUsername("sa").withPassword("")
|
||||
.withJdbcUrl("jdbc:h2:mem:analysis_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.StandInAnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
/*******************************************************************************
|
||||
** RAND samples fluctuate around population counts without changing drill scope.
|
||||
*******************************************************************************/
|
||||
class H2SamplingTest extends EngineFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Ordinal grouping is supported by H2's PostgreSQL compatibility mode.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected RDBMSBackendMetaData database()
|
||||
{
|
||||
return (new RDBMSBackendMetaData().withVendor("h2").withUsername("sa").withPassword("")
|
||||
.withJdbcUrl("jdbc:h2:mem:sampling_" + java.util.UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE;MODE=PostgreSQL"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A large sample gives stable tolerances and includes filtered-out records.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void randomCountsAndHistograms() throws Exception
|
||||
{
|
||||
try(var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO orders (id, customer, amount) SELECT x, 1, CASE WHEN MOD(x, 4) = 0 THEN NULL ELSE MOD(x, 10) END FROM SYSTEM_RANGE(1001, 11000) AS source(x)");
|
||||
}
|
||||
config.withDefaultSampleThresholdRows(100).withDefaultSampleRows(2000);
|
||||
service.getConfigResolver().invalidate();
|
||||
var filter = DrillFilters.condition("id", QCriteriaOperator.GREATER_THAN, 1000);
|
||||
var profile = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("amount").withFilter(filter));
|
||||
assertThat(profile.getEstimate()).isTrue();
|
||||
var metrics = profile.getCounts().stream().collect(java.util.stream.Collectors.toMap(m -> m.getKey(), m -> m));
|
||||
assertThat(metrics.get("rows").getValue()).isEqualTo(10000L);
|
||||
assertThat(metrics.get("rows").getEstimate()).isFalse();
|
||||
assertThat(((Number) metrics.get("nulls").getValue()).longValue()).isCloseTo(2500L, within(500L));
|
||||
assertThat(((Number) metrics.get("nonNull").getValue()).longValue()).isCloseTo(7500L, within(500L));
|
||||
assertThat(metrics.get("nonNull").getFormatted()).startsWith("≈ ");
|
||||
assertThat(profile.getNumeric().getHistogram().stream().mapToLong(v -> v.getCount()).sum()).isCloseTo(7500L, within(700L));
|
||||
var exact = new StandInAnalysisBackend(service.getConfigResolver().resolve("orders"));
|
||||
assertThat(exact.count("orders", metrics.get("rows").getDrill().getFilter())).isEqualTo(10000);
|
||||
for(var value : profile.getTopValues())
|
||||
{
|
||||
long population = exact.count("orders", value.getDrill().getFilter());
|
||||
assertThat(value.getCount()).isCloseTo(population, within(500L));
|
||||
}
|
||||
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(profile)).doesNotContain("\"operator\":\"IN\"");
|
||||
var table = service.tableProfile(new com.kingsrook.qbits.dataanalysis.result.TableProfileRequest().withTableName("orders").withFilter(filter).withFields(List.of("amount")));
|
||||
assertThat(table.getRowCount().getValue()).isEqualTo(10000L);
|
||||
assertThat(table.getEstimate()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.AnalysisBackend;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.StandInAnalysisBackend;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Run every engine contract assertion with the portable streamed implementation.
|
||||
*******************************************************************************/
|
||||
class H2StreamedEngineTest extends H2EngineTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Change only the backend under test, preserving the entire shared contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected DataAnalysisService newService(DataAnalysisQBitConfig configuration, Executor executor)
|
||||
{
|
||||
return (new DataAnalysisService(configuration, executor)
|
||||
{
|
||||
@Override
|
||||
public AnalysisBackend backend(String table) throws QException
|
||||
{
|
||||
return (new StandInAnalysisBackend(getConfigResolver().resolve(table)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Memory reference implementation of the shared engine contract.
|
||||
*******************************************************************************/
|
||||
class MemoryEngineTest extends EngineContractTest
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** A separate JVM proves the optional middleware is not needed by the engine.
|
||||
*******************************************************************************/
|
||||
class OptionalMiddlewareTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Remove Javalin and middleware artifacts from the actual test classpath.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void engineWithoutMiddleware() throws Exception
|
||||
{
|
||||
String classpath = Arrays.stream(System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")).split(java.io.File.pathSeparator))
|
||||
.filter(entry -> !entry.contains("qqq-middleware-javalin") && !Path.of(entry).getFileName().toString().startsWith("javalin-"))
|
||||
.collect(Collectors.joining(java.io.File.pathSeparator));
|
||||
Path log = Path.of("target", "core-only-smoke.log");
|
||||
Process process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-cp", classpath, CoreOnlySmoke.class.getName())
|
||||
.redirectErrorStream(true).redirectOutput(log.toFile()).start();
|
||||
try
|
||||
{
|
||||
assertThat(process.waitFor(30, TimeUnit.SECONDS)).as("Core-only JVM timed out").isTrue();
|
||||
assertThat(process.exitValue()).as(java.nio.file.Files.readString(log)).isZero();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(process.isAlive())
|
||||
{
|
||||
process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point in the classpath with middleware removed.
|
||||
*******************************************************************************/
|
||||
public static class CoreOnlySmoke
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata and runtime must initialize without loading any API classes.
|
||||
*******************************************************************************/
|
||||
public static void main(String[] args) throws Exception
|
||||
{
|
||||
var instance = TestSupport.instance();
|
||||
assertThat(instance.getSupplementalMetaData("dataAnalysisRuntime")).isNotNull();
|
||||
assertThat(instance.getSupplementalMetaData("middlewareSpecExtensions")).isNull();
|
||||
assertThat(instance.getTable("data_analysis_run")).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
|
||||
/*******************************************************************************
|
||||
** Actual PostgreSQL tests; skip cleanly if Docker is unavailable.
|
||||
*******************************************************************************/
|
||||
class PostgresEngineTest extends EngineContractTest
|
||||
{
|
||||
private static PostgreSQLContainer postgres;
|
||||
|
||||
/*******************************************************************************
|
||||
** Never substitute another database for the PostgreSQL contract.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void start()
|
||||
{
|
||||
if(DockerClientFactory.instance().isDockerAvailable())
|
||||
{
|
||||
postgres = new PostgreSQLContainer("postgres:17-alpine");
|
||||
postgres.start();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Release the test container.
|
||||
*******************************************************************************/
|
||||
@AfterAll
|
||||
static void stop()
|
||||
{
|
||||
if(postgres != null)
|
||||
{
|
||||
postgres.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Large populations never become one bind per sampled primary key.
|
||||
*******************************************************************************/
|
||||
@org.junit.jupiter.api.Test
|
||||
void largeEstimate() throws Exception
|
||||
{
|
||||
try(var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("TRUNCATE TABLE orders");
|
||||
statement.execute("INSERT INTO orders (id, customer, paid) SELECT id, 1, id % 2 = 0 FROM generate_series(1, 50000) AS id");
|
||||
}
|
||||
config.withDefaultSampleThresholdRows(100).withDefaultSampleRows(1000);
|
||||
service.getConfigResolver().invalidate();
|
||||
var profile = service.columnProfile(new com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest().withTableName("orders").withFieldName("paid"));
|
||||
org.assertj.core.api.Assertions.assertThat(profile.getEstimate()).isTrue();
|
||||
assertMetric(profile.getCounts(), "rows", "50000");
|
||||
org.assertj.core.api.Assertions.assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(profile)).doesNotContain("\"operator\":\"IN\"");
|
||||
// Also exceed the old parameter ceiling with the requested sample itself.
|
||||
config.withDefaultSampleRows(40000);
|
||||
service.getConfigResolver().invalidate();
|
||||
assertMetric(service.columnProfile(new com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest().withTableName("orders").withFieldName("paid")).getCounts(), "rows", "50000");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Credentials belong only to the ephemeral test container.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected RDBMSBackendMetaData database()
|
||||
{
|
||||
Assumptions.assumeTrue(postgres != null, "Docker unavailable; PostgreSQL contract skipped");
|
||||
return (new PostgreSQLBackendMetaData().withJdbcUrl(postgres.getJdbcUrl())
|
||||
.withUsername(postgres.getUsername()).withPassword(postgres.getPassword()));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user