Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de05490660
|
No files matched your search
+11
-68
@@ -1,71 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.14
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
|
||||
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+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.14
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /.*/
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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
|
||||
@@ -1,3 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
*.cmd text eol=crlf
|
||||
mvnw text eol=lf
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
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,3 @@
|
||||
profile: repository-policy
|
||||
orb_version: 0.7.14
|
||||
default_branch: develop
|
||||
@@ -1,77 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget on the Runner page; the app's home is a dashboard (`home/`). Design: `docs/DESIGN.md`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
|
||||
./mvnw test # Unit tests (H2 MODE=MySQL, SQLite, Javalin over HTTP)
|
||||
./mvnw test -Dtest=ClassName
|
||||
CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo gate (70% instructions, 90% classes)
|
||||
./mvnw verify -Dpostgres.test.image=postgres:17-alpine
|
||||
```
|
||||
|
||||
How tests are split:
|
||||
- **Integration tests** (`*IntegrationTest`) run under failsafe and use `@ExtendWith(RequiresDockerCondition.class)`. Without Docker they are skipped locally; with `CI=true` the build fails instead.
|
||||
- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets the user permission list, `writer-*` adds write and share, `admin-*` gets the admin list, anything else gets nothing. `loginAs(user, groups...)` / `as(user)` also apply the security keys; notebook calls must run with the matching session. `TableSecurityMatrixTest` is the role x table x action matrix.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point:** `SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkbenchQBitConfig>`.
|
||||
- Component producers are found in the `metadata` package only (`getPackageNameForFindingMetaDataProducers`), because `api` needs the optional Javalin dependency.
|
||||
- `postProduceActions` builds `SqlWorkbenchRuntime` and calls `api.SqlWorkbenchRoutes.register` when Javalin is on the classpath.
|
||||
|
||||
**Packages:**
|
||||
- `sql/`: `SqlScanner` (dialect-aware lexer), `SqlClassifier` (split, then classify as READ, EXPLAIN_ANALYZE, WRITE, DDL, or FORBIDDEN), `NamedParameterSql` (`:name` → `?`). Pure Java.
|
||||
- `engine/`:
|
||||
- `SqlExecutionEngine` handles read-only runs, the write preview/commit protocol, and EXPLAIN.
|
||||
- `SessionGuard` sets up the per-vendor read-only transaction and timeouts, and always restores the connection (or aborts it).
|
||||
- `WorkbenchBackends` resolves targets and derives backends for dedicated credentials.
|
||||
- `ResultCollector` converts types and applies the row/byte caps.
|
||||
- `ResultCache`, `RunningStatements`, and `WritePreviewTokens` are the runtime state.
|
||||
- `ExecutionAuditor` writes audits through QQQ actions as `QSystemUserSession`.
|
||||
- `explain/`: `PlanParser` (Postgres JSON, MySQL JSON/text, SQLite rows, text) and `PlanNode`.
|
||||
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
|
||||
- `notebook/`: `NotebookService` (CRUD, sharing, versions in one transaction, snapshots), run as the caller's session through the standard table actions; `NotebookVersioning` (one version per change, as the system user).
|
||||
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
|
||||
- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, the home dashboard widgets, the Runner child app, and the `sqlWorkbench` app (nav: Runner, then the main tables only).
|
||||
- `home/`: the dashboard data (`SqlWorkbenchHome`, read through core actions in the viewer's session so locks apply), its renderer, and inline-styled HTML helpers.
|
||||
- `SqlWorkbenchIntegrations`: opt-in ESB and Quick Search hooks for the main tables.
|
||||
- `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions.
|
||||
- `processes/`: the record-action steps (Share, Duplicate, Restore Version).
|
||||
- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals).
|
||||
|
||||
**Invariants (do not break):**
|
||||
- Never log SQL text, parameters, or results. Only the audit table holds them.
|
||||
- Audit `start()` runs before any SQL. If it fails, the SQL does not run.
|
||||
- Audit updates are deferred until the session's connection is released (`Deferred` in the engine), so the engine never needs two pooled connections at once.
|
||||
- Engine-side session settings must be transaction-scoped (`SET LOCAL`) or reverted in `SessionGuard.close`.
|
||||
- Workbench rows are read and written as the caller (record locks apply). Only the audit writes, version bookkeeping, cascades and share-visibility sync run as the system user, after QQQ authorized the change. Never re-hide the tables or bypass the locks to fix a permission problem.
|
||||
- Table customizers hold the notebook invariants; the notebook UI and the table UI must behave the same.
|
||||
- SQL errors are results (`status` FAILED, TIMED_OUT, or CANCELLED). Exceptions are only for 400, 401, 403, and 404.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 3-space indentation, opening braces on the next line, `return (x);`.
|
||||
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
|
||||
- Fluent setters (`withX()`).
|
||||
- Wrapper types (`Integer`, `Boolean`) in config and entities.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional).
|
||||
- Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
|
||||
|
||||
## [1.0.0-RC.3] - Unreleased
|
||||
|
||||
### Added
|
||||
- **App home dashboard.** The `sqlWorkbench` app's home is now an information dashboard of real data, read in the viewer's session (permissions and record locks apply): summary tiles and links, runs per day, recent notebooks, recent runs, slowest and failed queries, most-used notebooks, shared notebooks, committed writes, and (workbench admins only) activity by user. Each widget has an honest empty state.
|
||||
- **Runner.** The notebook editor (`SQL_WORKBENCH` widget) moves to its own nav entry: the `sqlWorkbenchRunner` child app, labeled Runner. Open a notebook with `/app/sqlWorkbenchRunner?notebook=<id>`.
|
||||
- On a notebook's record view: an Open in Runner widget, and a Runs child list (its executions the viewer may see) through the new join `sql_workbench_notebook_join_sql_workbench_execution`.
|
||||
- `SqlWorkbenchIntegrations`: opt-in ESB (`esbTableNames`) and Quick Search (`quickSearchTables`) hooks for the main tables, with no dependency on either module.
|
||||
- **Record developer panel** "SQL Workbench" (`SqlWorkbenchDeveloperPanelProvider`, registered by `SqlWorkbenchDeveloperPanelsMetaDataProducer`): read-only `SELECT`s for the record and its table in the backend's own names, backend details, and links that open them in the Runner as a new cell (`/app/sqlWorkbenchRunner?sql=&backend=`, never run). Requires QQQ 4.1.0-RC.4 (the developer panel contract) and a Next UI with the Runner `?sql=` support.
|
||||
|
||||
### Changed
|
||||
- **The nav lists only the main tables**: Notebooks and the execution audit, after the Runner. Cells, versions, pinned results and shares are child lists on a notebook (still standard tables, reachable by URL and API). The app's widget list is now the dashboard, not the `SQL_WORKBENCH` widget; links to `/app/sqlWorkbench` that expected the editor should point at `/app/sqlWorkbenchRunner`.
|
||||
- The notebook record's header shows the description.
|
||||
- **Names follow the owner rule: lowercase snake_case, prefixed with the qbit's name (metadata only, no DDL).** Tables `sql_workbench_notebook`, `sql_workbench_cell`, `sql_workbench_notebook_version`, `sql_workbench_result_snapshot`, `sql_workbench_notebook_share`, `sql_workbench_execution` (they now equal their backend table names); possible-value source `sql_workbench_share_audience_type`; permissions `sql_workbench.*`, `sql_workbench_admin.hasAccess` and `<table>.read` etc.; security key types `sql_workbench_user` (all-access `sql_workbench_all_access`) and `sql_workbench_audience`; joins `sql_workbench_notebook_join_sql_workbench_*`.
|
||||
- `tableNamePrefix` (and the QBit namespace) is normalized to lowercase snake_case and joined with one `_`.
|
||||
|
||||
### Deprecated
|
||||
- `LEGACY_TABLE_NAME` on each entity, `ShareAudienceType.LEGACY_NAME` and `SqlWorkbenchQBitConfig.LEGACY_*` hold the pre-RC.3 names, for migrating stored references only.
|
||||
|
||||
### Upgrade from RC.2
|
||||
- Follow [UPGRADE.md](UPGRADE.md): grant the renamed permissions and rename stored table names (audit, saved views and reports). No DDL.
|
||||
|
||||
## [1.0.0-RC.2] - Unreleased
|
||||
|
||||
The workbench's data becomes standard QQQ tables, secured by QQQ's own mechanisms instead of being hidden ([#3](https://github.com/QRun-IO/qbit-sql-workbench/issues/3)). Requires QQQ `4.1.0-RC.4` or later ([qqq#1073](https://github.com/QRun-IO/qqq/issues/1073)).
|
||||
|
||||
### Added
|
||||
- `sqlWorkbenchNotebookShare`: share a notebook, read-only, with a user, a group, or everyone (`ALL`, the shared library). Publishing in the notebook UI now writes an `ALL` share; a notebook's `visibility` follows its shares.
|
||||
- Record security locks on every table, enforced by QQQ's core actions (table UI, `/qqq/v1` table API, export, PVS lookups, processes, the workbench endpoints): owners see and change their notebooks and children, share audiences read them, users read their own execution audit, admins (`sqlWorkbenchAdmin.hasAccess`) hold the all-access key. Security key types `sqlWorkbenchUser` and `sqlWorkbenchAudience`; `SqlWorkbenchSecurity.applyToSession(qInstance, session, groups)` puts the keys in a session.
|
||||
- Per-table `read` / `insert` / `edit` / `delete` permissions, listed per role by `SqlWorkbenchQBitConfig.getUserPermissionNames()`, `getSharePermissionNames()` and `getAdminPermissionNames()`.
|
||||
- All six tables are children of the `sqlWorkbench` app, with record-view sections, child-list widgets (cells, shares, pinned results, versions) and SQL / JSON code-editor adornments.
|
||||
- Record actions (processes on tables, not in the nav): Share Notebook, Duplicate Notebook, Restore Version.
|
||||
- Table customizers that keep the notebook invariants for every entry point: ownership from the session, cell validation and ordering, one version per change, share keys and visibility, append-only versions and audit (also for admins), pinned results only from pinning, cascading notebook deletes.
|
||||
- Record-level QQQ audits for notebooks, cells, pinned results and shares.
|
||||
|
||||
### Changed
|
||||
- `NotebookService` and the history endpoint run as the caller's session through the standard table actions (no system-user bypass); the explicit owner / share checks stay as defense in depth.
|
||||
- The tables' permission rules are per-table permissions instead of `sqlWorkbenchAdmin.hasAccess`. Hosts must grant the new lists and call `applyToSession`.
|
||||
- Built against QQQ `4.1.0-RC.4`.
|
||||
|
||||
### Upgrade from RC.1
|
||||
- Run the share-table DDL from `docs/ddl/postgres.sql`: it creates `sql_workbench_notebook_share` and backfills an `all` share for each `SHARED` notebook (additive, idempotent; owners unchanged).
|
||||
- Grant the permission lists and call `SqlWorkbenchSecurity.applyToSession` in the authentication customizer.
|
||||
|
||||
## [1.0.0-RC.1] - 2026-10-11
|
||||
|
||||
First release candidate of 1.0, published to Maven Central from `release/1.0`. It contains every change listed under 1.0.0 below. Requires QQQ 4.1 (built against 4.1.0-RC.2) and a Next UI release that renders the `SQL_WORKBENCH` widget (`v1.0.0-RC.13` or later).
|
||||
|
||||
## [1.0.0] - Unreleased
|
||||
|
||||
First release. Requires QQQ 4.1 (built against 4.1.0-RC.2).
|
||||
|
||||
### Added
|
||||
- `SqlWorkbenchQBitProducer` / `SqlWorkbenchQBitConfig`: a standard QQQ 4.1 QBit, published as `com.kingsrook.qbits:qbit-sql-workbench`.
|
||||
- Tables:
|
||||
- `sqlWorkbenchNotebook`, `sqlWorkbenchCell`, `sqlWorkbenchNotebookVersion`, `sqlWorkbenchResultSnapshot`.
|
||||
- `sqlWorkbenchExecution`: the audit table, read-only for users.
|
||||
- The `SQL_WORKBENCH` widget and the `sqlWorkbench` app. There are no processes.
|
||||
- Execution engine:
|
||||
- Read-only by default: a database `READ ONLY` transaction plus a statement classifier.
|
||||
- Gated write mode: preview with a rolled-back dry run, single-use confirmation tokens, a row guard, a hard cap, and a drift check.
|
||||
- Per-vendor session guard with clean connection return.
|
||||
- Statement timeouts, row and byte caps, cancellation, a per-user concurrency limit.
|
||||
- Dedicated read-only and write credentials per backend, plus use of the backend's read-only replica.
|
||||
- EXPLAIN / EXPLAIN ANALYZE (always rolled back) normalized into a plan tree for Postgres, MySQL, SQLite, and H2.
|
||||
- Schema introspection for autocomplete, with QQQ table and field to SQL name mapping.
|
||||
- Notebooks:
|
||||
- Personal notebooks and a shared library.
|
||||
- Named parameters.
|
||||
- Version history with retention.
|
||||
- Pinned result snapshots.
|
||||
- REST API under `/qqq/v1/sql-workbench`, with CSV/XLSX/JSON export.
|
||||
- PostgreSQL DDL in `docs/ddl/postgres.sql`.
|
||||
|
||||
### Security
|
||||
- The qbit's tables need a separate admin permission (`adminPermissionBaseName`, default `sqlWorkbenchAdmin` => `sqlWorkbenchAdmin.hasAccess`). Ordinary workbench users have no generic table API access to notebooks, snapshots or the audit; the workbench endpoints check ownership and act as the system user.
|
||||
- Client-chosen `executionUuid`s are single-use (409 on reuse) and case-insensitive.
|
||||
- On MySQL, executable comments (`/*! */`, `/*M! */`) and optimizer hints (`/*+ */`) are refused.
|
||||
- CSV exports neutralize formula injection; connection errors are generic to the caller.
|
||||
- Production backends should configure `readOnlyCredentials` (see README, Safety model).
|
||||
@@ -1,77 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget on the Runner page; the app's home is a dashboard (`home/`). Design: `docs/DESIGN.md`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
|
||||
./mvnw test # Unit tests (H2 MODE=MySQL, SQLite, Javalin over HTTP)
|
||||
./mvnw test -Dtest=ClassName
|
||||
CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo gate (70% instructions, 90% classes)
|
||||
./mvnw verify -Dpostgres.test.image=postgres:17-alpine
|
||||
```
|
||||
|
||||
How tests are split:
|
||||
- **Integration tests** (`*IntegrationTest`) run under failsafe and use `@ExtendWith(RequiresDockerCondition.class)`. Without Docker they are skipped locally; with `CI=true` the build fails instead.
|
||||
- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets the user permission list, `writer-*` adds write and share, `admin-*` gets the admin list, anything else gets nothing. `loginAs(user, groups...)` / `as(user)` also apply the security keys; notebook calls must run with the matching session. `TableSecurityMatrixTest` is the role x table x action matrix.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point:** `SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkbenchQBitConfig>`.
|
||||
- Component producers are found in the `metadata` package only (`getPackageNameForFindingMetaDataProducers`), because `api` needs the optional Javalin dependency.
|
||||
- `postProduceActions` builds `SqlWorkbenchRuntime` and calls `api.SqlWorkbenchRoutes.register` when Javalin is on the classpath.
|
||||
|
||||
**Packages:**
|
||||
- `sql/`: `SqlScanner` (dialect-aware lexer), `SqlClassifier` (split, then classify as READ, EXPLAIN_ANALYZE, WRITE, DDL, or FORBIDDEN), `NamedParameterSql` (`:name` → `?`). Pure Java.
|
||||
- `engine/`:
|
||||
- `SqlExecutionEngine` handles read-only runs, the write preview/commit protocol, and EXPLAIN.
|
||||
- `SessionGuard` sets up the per-vendor read-only transaction and timeouts, and always restores the connection (or aborts it).
|
||||
- `WorkbenchBackends` resolves targets and derives backends for dedicated credentials.
|
||||
- `ResultCollector` converts types and applies the row/byte caps.
|
||||
- `ResultCache`, `RunningStatements`, and `WritePreviewTokens` are the runtime state.
|
||||
- `ExecutionAuditor` writes audits through QQQ actions as `QSystemUserSession`.
|
||||
- `explain/`: `PlanParser` (Postgres JSON, MySQL JSON/text, SQLite rows, text) and `PlanNode`.
|
||||
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
|
||||
- `notebook/`: `NotebookService` (CRUD, sharing, versions in one transaction, snapshots), run as the caller's session through the standard table actions; `NotebookVersioning` (one version per change, as the system user).
|
||||
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
|
||||
- `metadata/`: the 6 tables (record locks, joins, child-list widgets), the security key types, the PVSs, the record actions, the `SQL_WORKBENCH` widget and its renderer, the home dashboard widgets, the Runner child app, and the `sqlWorkbench` app (nav: Runner, then the main tables only).
|
||||
- `home/`: the dashboard data (`SqlWorkbenchHome`, read through core actions in the viewer's session so locks apply), its renderer, and inline-styled HTML helpers.
|
||||
- `SqlWorkbenchIntegrations`: opt-in ESB and Quick Search hooks for the main tables.
|
||||
- `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions.
|
||||
- `processes/`: the record-action steps (Share, Duplicate, Restore Version).
|
||||
- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals).
|
||||
|
||||
**Invariants (do not break):**
|
||||
- Never log SQL text, parameters, or results. Only the audit table holds them.
|
||||
- Audit `start()` runs before any SQL. If it fails, the SQL does not run.
|
||||
- Audit updates are deferred until the session's connection is released (`Deferred` in the engine), so the engine never needs two pooled connections at once.
|
||||
- Engine-side session settings must be transaction-scoped (`SET LOCAL`) or reverted in `SessionGuard.close`.
|
||||
- Workbench rows are read and written as the caller (record locks apply). Only the audit writes, version bookkeeping, cascades and share-visibility sync run as the system user, after QQQ authorized the change. Never re-hide the tables or bypass the locks to fix a permission problem.
|
||||
- Table customizers hold the notebook invariants; the notebook UI and the table UI must behave the same.
|
||||
- SQL errors are results (`status` FAILED, TIMED_OUT, or CANCELLED). Exceptions are only for 400, 401, 403, and 404.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 3-space indentation, opening braces on the next line, `return (x);`.
|
||||
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
|
||||
- Fluent setters (`withX()`).
|
||||
- Wrapper types (`Integer`, `Boolean`) in config and entities.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional).
|
||||
- Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
A super-user SQL notebook for any [QQQ](https://github.com/QRun-IO/qqq) application.
|
||||
|
||||
You write SQL and markdown in notebook cells, run them against any RDBMS backend of the instance (Postgres, MySQL, SQLite, H2), and see typed, paged, exportable results, quick charts, and visual EXPLAIN plans. The notebook UI (the **Runner**) lives in the [Next UI](https://github.com/QRun-IO/qqq-frontend-next) as a `SQL_WORKBENCH` widget on its own page in the app's nav. The app's home page is an information dashboard over the notebooks and the execution audit.
|
||||
You write SQL and markdown in notebook cells, run them against any RDBMS backend of the instance (Postgres, MySQL, SQLite, H2), and see typed, paged, exportable results, quick charts, and visual EXPLAIN plans. The notebook UI lives in the [Next UI](https://github.com/QRun-IO/qqq-frontend-next) as a `SQL_WORKBENCH` widget on the qbit's app page.
|
||||
|
||||
- **Read-only by default.** A default session runs one statement inside a database `READ ONLY` transaction, and a statement classifier refuses anything that isn't a read.
|
||||
- **Gated write mode.** Writes need a separate permission and two steps. First a dry run executes in a transaction that is rolled back, so you see the affected-row counts. You then confirm with a single-use token bound to that exact SQL. A row guard, a hard cap, a drift check, and statement timeouts apply.
|
||||
- **Everything is audited.** The `sql_workbench_execution` table records who ran what, when, on which backend, in which mode, how long it took, how many rows it returned or affected, and any error. SQL text and results never go to the logs.
|
||||
- **Notebooks.** Notebooks are personal by default and can be shared, read-only, with users, groups, or everyone (the shared library). They have named `:parameters` (always bound, never spliced), version history, and pinned result snapshots.
|
||||
- **Standard QQQ tables.** Notebooks, cells, versions, pinned results, shares, and the execution audit are ordinary QQQ tables, with the usual query, record, edit, export, audit, and developer-mode screens. The nav lists the main tables (Notebooks and the execution audit); the others are child lists on a notebook's record view and stay reachable by URL and the table API. Record security locks decide who sees and changes which rows, for every entry point.
|
||||
- **Everything is audited.** The `sqlWorkbenchExecution` table records who ran what, when, on which backend, in which mode, how long it took, how many rows it returned or affected, and any error. SQL text and results never go to the logs.
|
||||
- **Notebooks.** Notebooks are personal by default and can be published to a shared library. They have named `:parameters` (always bound, never spliced), version history, and pinned result snapshots.
|
||||
|
||||
> **Raw SQL bypasses QQQ.** It ignores record security locks, table and field permissions, hidden fields, and record locks. It also skips QQQ audits, record-change listeners, automations, ESB events, and search indexing. That is why the workbench is for super-users only. Use QQQ processes for business changes.
|
||||
|
||||
@@ -16,7 +15,7 @@ The full design (architecture, data model, security model, limits, API contract,
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21 and QQQ 4.1 (built against `4.1.0-RC.4`, which is required: it lets partial edits of child rows pass joined record locks, [qqq#1073](https://github.com/QRun-IO/qqq/issues/1073)).
|
||||
- Java 21 and QQQ 4.1 (built against `4.1.0-RC.1`).
|
||||
- `qqq-backend-module-rdbms` for the targets. `qqq-middleware-javalin` for the REST routes; it is optional, and without it the engine still works but no routes are registered.
|
||||
- A Next UI release that renders the `SQL_WORKBENCH` widget.
|
||||
|
||||
@@ -26,13 +25,13 @@ The full design (architecture, data model, security model, limits, API contract,
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-sql-workbench</artifactId>
|
||||
<version>1.0.0-RC.2</version>
|
||||
<version>1.0.0-RC.1</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
```java
|
||||
SqlWorkbenchQBitConfig config = new SqlWorkbenchQBitConfig()
|
||||
.withBackendName("postgres") // where the qbit's 6 tables live
|
||||
.withBackendName("postgres") // where the qbit's 5 tables live
|
||||
.withTableMetaDataCustomizer(new SnakeCaseCustomizer()) // optional: map names to snake_case
|
||||
.withReadOnlyCredentials("postgres", new SqlWorkbenchCredentials()
|
||||
.withUsername("${env.SQLWB_RO_USER}") // optional, recommended: a SELECT-only role
|
||||
@@ -43,49 +42,17 @@ new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
|
||||
The producer adds the following to the instance:
|
||||
|
||||
- **Tables:** `sql_workbench_notebook`, `sql_workbench_cell`, `sql_workbench_notebook_version`, `sql_workbench_result_snapshot`, `sql_workbench_notebook_share`, `sql_workbench_execution`, each with record security locks, per-table permissions, and table customizers ([Permissions](#permissions)).
|
||||
- **Security key types** `sql_workbench_user` (all-access key `sql_workbench_all_access`) and `sql_workbench_audience`.
|
||||
- **Joins** from a notebook to its cells, versions, pinned results, shares, and runs (execution audit rows), with child-list widgets on the notebook's record view, plus an Open in Runner widget there.
|
||||
- **Possible value sources** over the notebook table and the share audience types.
|
||||
- **Record actions** (processes on a table, never in the nav): Share Notebook, Duplicate Notebook, Restore Version.
|
||||
- **The `sqlWorkbenchWidget` widget** (type `SQL_WORKBENCH`): the notebook editor.
|
||||
- **The home dashboard widgets** ([App home](#app-home)).
|
||||
- **The `sqlWorkbench` app.** Its home page is the dashboard. Its nav lists the **Runner** (`sqlWorkbenchRunner`, a child app whose page is the `SQL_WORKBENCH` widget), then the main tables: Notebooks (`sql_workbench_notebook`) and the execution audit (`sql_workbench_execution`). Cells, versions, pinned results and shares are not in the nav; they are child lists on a notebook.
|
||||
- **Tables:** `sqlWorkbenchNotebook`, `sqlWorkbenchCell`, `sqlWorkbenchNotebookVersion`, `sqlWorkbenchResultSnapshot`, `sqlWorkbenchExecution`.
|
||||
- **A possible value source** over the notebook table.
|
||||
- **The `sqlWorkbenchWidget` widget** (type `SQL_WORKBENCH`).
|
||||
- **The `sqlWorkbench` app**, which contains the widget plus the notebook and audit tables. It has no processes.
|
||||
- **The `/qqq/v1/sql-workbench/*` routes**, registered through `QJavalinMetaData`, as `qqq-esb` does.
|
||||
|
||||
To move the app under another app (for example an Admin app), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing; the Runner moves with it. Open a notebook in the Runner with `/app/sqlWorkbenchRunner?notebook=<id>`.
|
||||
|
||||
## App home
|
||||
|
||||
The app's home page is a dashboard of real data, read through QQQ's core actions in the viewer's session. Permissions and record locks therefore decide what each widget counts: a user sees their own runs and the notebooks they can open, and a workbench admin sees everyone's. A widget whose table the user may not read says so rather than showing zeros. Every widget is full width and has an empty state.
|
||||
|
||||
| Widget | Shows |
|
||||
|---|---|
|
||||
| SQL Workbench | Tiles: notebooks (yours, shared with you), runs today, runs and failures in 7 days, committed writes and rows affected in 30 days. Links to the Runner, Notebooks and the execution audit. |
|
||||
| Runs per Day | Runs, failures and writes per day, last 30 days. |
|
||||
| Recent Notebooks | Last changed notebooks, owner, sharing, with Open in Runner links. |
|
||||
| Recent Runs | Latest runs: when, who, SQL (linked to the audit row), backend, notebook, status, duration, rows. |
|
||||
| Slowest Queries, 30 Days | Longest runs, linked. |
|
||||
| Failed Queries, 30 Days | Failed, timed-out, rejected and rolled-back runs with their error, linked. |
|
||||
| Most-Used Notebooks, 30 Days | Notebooks by runs, with users and last run. |
|
||||
| Shared Notebooks | Notebooks shared with users, groups or everyone, and since when. |
|
||||
| Writes | Committed writes (gated write mode): who, what, when, rows affected, result, and whether write mode is on. |
|
||||
| Activity by User, 30 Days | Runs, failures and writes per user. Workbench admins only. |
|
||||
|
||||
The home owns this data; a host's other dashboards should link to it rather than repeat it.
|
||||
|
||||
## ESB and Quick Search
|
||||
|
||||
`SqlWorkbenchIntegrations` is an opt-in hook: it names what to wire, and the host wires it with its own modules (the qbit depends on neither).
|
||||
|
||||
- **ESB:** `esbTableNames(config)` lists the main tables. Publish their insert, update and delete events the usual way, for example `EsbTableMetaData.ofOrWithNew(qInstance.getTable(name)).withPublication(new EsbTablePublication().withDestinationName(...).withEvents(...))`. Execution rows are inserted when a run starts and updated when it ends.
|
||||
- **Quick Search:** `quickSearchTables(config)` lists the main tables with weighted fields, the basepull timestamp field and a hit label. Notebooks: title, description, owner, default backend. Executions: SQL text, execution UUID, user, error, backend. Map each to a `SearchableTableConfig` (with `new SearchableFieldConfig(name).withWeight(w)`). Hits are re-read through the tables' record locks, so a user finds only what they could open. The Quick Search record developer panel then covers these tables too.
|
||||
|
||||
What leaves the database: a notebook event or search document carries the notebook row. An execution event carries the audit row, which includes the SQL text and parameter values, and its search document includes the SQL text. Results are never stored, so they are never published or indexed. Decide whether that suits your broker and search cluster before you opt in.
|
||||
To move the app under another app (for example an Admin app), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing.
|
||||
|
||||
## Storage
|
||||
|
||||
Create the six tables on `backendName`. [docs/ddl/postgres.sql](docs/ddl/postgres.sql) has idempotent PostgreSQL DDL that you can paste into a Liquibase formatted-SQL changeset.
|
||||
Create the five tables on `backendName`. [docs/ddl/postgres.sql](docs/ddl/postgres.sql) has idempotent PostgreSQL DDL that you can paste into a Liquibase formatted-SQL changeset.
|
||||
|
||||
The DDL uses snake_case names: `sql_workbench_notebook`, `owner_user_id`, and so on. Match them with a table customizer:
|
||||
|
||||
@@ -101,74 +68,24 @@ public class SnakeCaseCustomizer implements MetaDataCustomizerInterface<QTableMe
|
||||
}
|
||||
```
|
||||
|
||||
## Storage upgrade from 1.0.0-RC.1
|
||||
|
||||
RC.2 adds `sql_workbench_notebook_share`. The DDL creates it and backfills an `all` share (the shared library) for every notebook whose `visibility` is `SHARED`; it is additive and idempotent, and owners (`owner_user_id`) are unchanged. Run it before deploying RC.2: without the share rows, published notebooks are visible only to their owners.
|
||||
|
||||
## Permissions
|
||||
|
||||
Everything is denied by default. Two standard QQQ layers apply:
|
||||
Everything is denied by default.
|
||||
|
||||
1. **Permissions** say which operations a user has on each table (QQQ's per-table `read`, `insert`, `edit`, `delete`) and on the endpoints.
|
||||
2. **Record security locks** say which rows. QQQ's core actions enforce them, so they apply to the table UI, the `/qqq/v1` table API, exports, possible-value lookups, processes, and the workbench's own endpoints alike.
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `sqlWorkbench.hasAccess` | The app, widget, tables, and every endpoint: read-only SQL, EXPLAIN, schema, notebooks, history |
|
||||
| `sqlWorkbench.write` | Write mode (preview and commit) and EXPLAIN ANALYZE |
|
||||
| `sqlWorkbench.share` | Publishing your notebooks to the shared library |
|
||||
|
||||
| Role | Grant | What it allows |
|
||||
|---|---|---|
|
||||
| User | `config.getUserPermissionNames()`: `sql_workbench.hasAccess`, plus read/insert/edit/delete on notebooks and cells, read on versions, shares and the audit, and read/edit/delete on pinned results | The app, widget and endpoints (read-only SQL, EXPLAIN, schema). Their own notebooks, read-only access to notebooks shared with them or in the library, and their own execution history. |
|
||||
| Writer | `config.getWritePermissionName()` (`sql_workbench.write`) | Write mode (preview and commit) and EXPLAIN ANALYZE. |
|
||||
| Sharer | `config.getSharePermissionNames()`: `sql_workbench.share`, plus insert/edit/delete on shares | Sharing their own notebooks with users, groups, or everyone (publishing to the library). |
|
||||
| Admin | `config.getAdminPermissionNames()`: the user permissions plus `sql_workbench_admin.hasAccess` | The all-access key: every user's notebooks, cells, versions, pinned results (real query data), shares, and the whole execution audit, and editing any notebook. |
|
||||
The names are configurable (`accessPermissionBaseName`, `writePermissionName`, `sharePermissionName`).
|
||||
|
||||
The names are configurable (`accessPermissionBaseName`, `writePermissionName`, `sharePermissionName`, `adminPermissionBaseName`). Table permissions follow the table names, including any `tableNamePrefix`. QQQ 4.1 does not list `write` and `share` as available permissions, so grant them where you grant roles, for example in an authentication customizer.
|
||||
|
||||
**Security keys.** Call `SqlWorkbenchSecurity.applyToSession(qInstance, session, groups)` from your authentication customizer, after the session's user and permissions are set. It puts these keys in the session:
|
||||
|
||||
| Key | Values | Used by |
|
||||
|---|---|---|
|
||||
| `sql_workbench_user` | the user's id (`QUser.idReference`) | owner locks: a notebook's `ownerUserId` (its children reach it through the notebook) and an execution's `userId` |
|
||||
| `sql_workbench_audience` | `user:<id>`, `group:<name>` for each group passed, and `all` | share locks (read only): a share row's `audienceKey` |
|
||||
| `sql_workbench_all_access` | `true` for holders of the admin permission | every row |
|
||||
|
||||
A session without the access permission gets no keys, so it finds no workbench rows at all, even through code paths that skip table permissions. If the host never calls `applyToSession`, the workbench endpoints add the user and `all` keys themselves, but group shares need the host to pass the groups.
|
||||
|
||||
**What the locks and customizers enforce:**
|
||||
|
||||
| Table | Read | Write |
|
||||
|---|---|---|
|
||||
| Notebook | the owner, a share audience of the session, or an admin | the owner or an admin. The owner and visibility are system-managed. |
|
||||
| Cell, version, pinned result | as their notebook | the owner or an admin, through the notebook. Versions are append-only. |
|
||||
| Share | the notebook's owner, the audience it names, or an admin | the owner or an admin, with the share permission |
|
||||
| Execution (audit) | the user who ran it, or an admin | nobody: append-only, only the qbit writes it |
|
||||
`write` and `share` are plain session permissions. QQQ 4.1 does not list them as available permissions, so grant them where you grant roles, for example in an authentication customizer.
|
||||
|
||||
`writeModeEnabled=false` turns write mode off for everyone.
|
||||
|
||||
## Record developer panel
|
||||
|
||||
With QQQ 4.1.0-RC.4 or later, the qbit registers a **SQL Workbench** panel for a record's developer view (`/app/{table}/{id}/dev` in the Next UI). It shows for tables on an RDBMS backend the workbench targets (honoring `allowedBackendNames` / `excludedBackendNames`), and only to users with `sql_workbench.hasAccess`. It contains:
|
||||
|
||||
- A read-only `SELECT` for the record and one for the table (`LIMIT 100`), in the backend's own table and column names (aliased to the QQQ field names), with identifiers quoted and the primary key written as an escaped literal for the dialect.
|
||||
- The backend, dialect, database table, and whether read-only sessions use the backend's read-only replica.
|
||||
- Links that open each query in the workbench (`/app/sqlWorkbenchRunner?sql=...&backend=...`, the Runner, with the table name prefix applied). The Runner opens the SQL as a new cell and does not run it.
|
||||
|
||||
The panel only builds text. What runs is what the user executes, under their workbench permissions; the write permission does not change the panel.
|
||||
|
||||
## Administering through the tables
|
||||
|
||||
Everything the Runner does can be done and inspected through the tables. Notebooks and the execution audit are in the SQL Workbench app's nav; the others open from a notebook's record view (or by URL). The same customizers run either way.
|
||||
|
||||
- **Notebooks**: create, rename, describe, delete (which removes its cells, versions, pinned results, and shares). The record view has an Open in Runner link and lists the notebook's cells, shares, pinned results, versions, and runs. Record actions: Share Notebook and Duplicate Notebook.
|
||||
- **Cells**: add (appended at the end, with a generated key), edit, reorder (`sortOrder`), delete. Each change saves a new notebook version, exactly like a save in the notebook UI.
|
||||
- **Versions**: read-only history. Restore Version saves the chosen version as a new one.
|
||||
- **Shares**: who can read a notebook. `USER` and `GROUP` shares name their audience, matched without regard to case (so user ids and group names must be case-insensitive in the host); `ALL` publishes the notebook to the shared library. The notebook's `visibility` follows its shares.
|
||||
- **Pinned results**: relabel or delete. Only pinning a result in the notebook UI creates them.
|
||||
- **Execution audit**: read, filter, and export. Nobody can edit or delete it through QQQ.
|
||||
|
||||
Notebooks, cells, pinned results, and shares get record-level QQQ audits, so SQL text, result data, and share audiences are not copied into the host's audit tables (whose readers the workbench locks do not reach).
|
||||
|
||||
## Safety model
|
||||
|
||||
> **Production: configure `readOnlyCredentials` for every target backend.** Use a dedicated database role with `SELECT` grants only: no write privileges, not a superuser, no `pg_signal_backend` (or MySQL `PROCESS` / `SUPER` / `CONNECTION_ADMIN`). Without it, read-only mode connects as the application's own role and relies on the classifier and the session's `READ ONLY` transaction alone. On H2 / generic backends there is no enforced read-only transaction, so the classifier is the only control. On Postgres a `READ ONLY` transaction still lets the application role call side-effecting functions such as `pg_terminate_backend()` or `pg_cancel_backend()`. `/capabilities` reports `readOnlyCredentials: false` / `readOnlyEnforced: false` per backend so you can check.
|
||||
|
||||
**Read-only sessions:**
|
||||
- Postgres: `setReadOnly(true)`, `SET TRANSACTION READ ONLY`, `SET LOCAL statement_timeout` and `lock_timeout`.
|
||||
- MySQL: `setReadOnly(true)`, which sets the session transaction characteristic; it is reverted afterwards.
|
||||
@@ -183,7 +100,6 @@ Notebooks, cells, pinned results, and shares get record-level QQQ audits, so SQL
|
||||
- It is conservative. Anything that isn't recognized as a read is treated as a write.
|
||||
- A "read" that mentions a mutating keyword is also a write: data-modifying CTEs, `SELECT ... INTO`, `FOR UPDATE`, `nextval()`.
|
||||
- Transaction and session control is refused in every mode: `BEGIN`, `COMMIT`, `SET`, `RESET`, `USE`, `PRAGMA x=y`, `ATTACH`, `DO`, and others.
|
||||
- On MySQL / MariaDB, comments the server executes are refused in every mode: `/*! ... */`, `/*M! ... */` (version-conditional SQL), and `/*+ ... */` (optimizer hints). `--` only starts a comment when whitespace follows, as in MySQL.
|
||||
|
||||
**Clean connections:**
|
||||
- Every session ends with a rollback (or a commit for a confirmed write) and restores autocommit and read-only.
|
||||
@@ -224,32 +140,6 @@ Base path: `/qqq/v1/sql-workbench`. The contract is in [docs/DESIGN.md §5](docs
|
||||
|
||||
POST and PUT bodies must be `application/json`.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
A checklist for adding the workbench to a QQQ application. Each row points to the section above that has the details.
|
||||
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-sql-workbench`. Current release candidate: `1.0.0-RC.2` (Maven Central, published from `release/1.0`). |
|
||||
| QQQ version | QQQ 4.1, `4.1.0-RC.4` or later (qqq#1073: partial edits of child rows under joined locks). Java 21. |
|
||||
| Dependencies | `qqq-backend-module-rdbms` comes in transitively. Add `qqq-middleware-javalin` yourself for the REST routes; it is optional in this qbit's POM, and without it no routes are registered. |
|
||||
| Frontend | A Next UI (`qqq-frontend-next`) release that renders the `SQL_WORKBENCH` widget: `v1.0.0-RC.13` or later. Other frontends show the app and tables but not the notebook. |
|
||||
| Registration | `new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance)` after your backends are in the instance. The producer registers its own QBit metadata; don't add it by hand. To nest the app (for example under Admin), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing. |
|
||||
| Storage | Create the six tables on `backendName` before first use ([Storage](#storage)). From RC.1, run the share-table DDL and its backfill ([upgrade](#storage-upgrade-from-100-rc1)). PostgreSQL DDL ships in [docs/ddl/postgres.sql](docs/ddl/postgres.sql) and drops into a Liquibase formatted-SQL changeset; translate it for other databases. Match the snake_case names with a `tableMetaDataCustomizer`. |
|
||||
| Targets | Every RDBMS backend in the instance is a target unless you narrow it with `allowedBackendNames` or `excludedBackendNames`. |
|
||||
| Secrets | In production, configure `readOnlyCredentials` for each target backend with a SELECT-only database role, and optionally `writeCredentials`. Pass them as `${env.X}` references (the example uses `SQLWB_RO_USER` and `SQLWB_RO_PASSWORD`; the names are yours). The qbit reads no other environment variables. |
|
||||
| Permissions | Grant `config.getUserPermissionNames()` to workbench users, plus `getWritePermissionName()` for write mode, `getSharePermissionNames()` for sharing, and `getAdminPermissionNames()` for administrators and auditors only. See [Permissions](#permissions). |
|
||||
| Security keys | Call `SqlWorkbenchSecurity.applyToSession(qInstance, session, groups)` in your authentication customizer after setting permissions. Pass the user's groups if you want group shares. |
|
||||
| Navigation | The app's home is the dashboard; its nav lists the Runner and the two main tables (Notebooks, the execution audit). The other four tables are child lists on a notebook; keep them out of explicit menus. The processes are record actions on the tables; keep them out of the nav. |
|
||||
| ESB and search | Optional: wire `SqlWorkbenchIntegrations.esbTableNames` and `quickSearchTables` into your ESB and Quick Search config ([ESB and Quick Search](#esb-and-quick-search)). |
|
||||
| Turning writes off | `withWriteModeEnabled(false)` disables write mode for everyone. |
|
||||
| Known limitations | Raw SQL bypasses QQQ security, audits, listeners and automations ([Safety model](#safety-model)). H2 and generic backends have no enforced read-only transaction. Only PostgreSQL DDL ships. Shares are read-only (no shared editing). |
|
||||
| Upgrading | From RC.2: the QQQ names became snake_case (`sql_workbench_*`; no DDL). See [UPGRADE.md](UPGRADE.md) for permissions and stored table names. From RC.1: also run the share-table DDL and backfill, grant the new permission lists (the tables no longer rely on the admin `hasAccess` permission alone), call `applyToSession`, and use QQQ `4.1.0-RC.4` or later. |
|
||||
|
||||
Nothing in the qbit is specific to one host application.
|
||||
|
||||
See also the [QQQ adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) for the full per-feature list (QQQ upgrade, Next dashboard, QBits, migrations, permissions).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
# Upgrading
|
||||
|
||||
## To 1.0.0-RC.3: snake_case names (metadata only)
|
||||
|
||||
The owner naming rule now applies to every QRun-IO qbit: table names are lowercase snake_case, prefixed with the qbit's name. In 1.0.0-RC.3 the SQL Workbench's QQQ names change to match. **The database does not change**: the physical tables were already `sql_workbench_*` (`docs/ddl/postgres.sql`), so no DDL runs. What changes is the names QQQ uses, and anything your app stored under the old names.
|
||||
|
||||
| Kind | Before (RC.1, RC.2) | From RC.3 |
|
||||
|---|---|---|
|
||||
| Tables (and the notebook possible-value source) | `sqlWorkbenchNotebook`, `sqlWorkbenchCell`, `sqlWorkbenchNotebookVersion`, `sqlWorkbenchResultSnapshot`, `sqlWorkbenchNotebookShare`, `sqlWorkbenchExecution` | `sql_workbench_notebook`, `sql_workbench_cell`, `sql_workbench_notebook_version`, `sql_workbench_result_snapshot`, `sql_workbench_notebook_share`, `sql_workbench_execution` |
|
||||
| Enum possible-value source | `sqlWorkbenchShareAudienceType` | `sql_workbench_share_audience_type` |
|
||||
| Permissions | `sqlWorkbench.hasAccess`, `sqlWorkbench.write`, `sqlWorkbench.share`, `sqlWorkbenchAdmin.hasAccess`, `sqlWorkbenchNotebook.read` etc. | `sql_workbench.hasAccess`, `sql_workbench.write`, `sql_workbench.share`, `sql_workbench_admin.hasAccess`, `sql_workbench_notebook.read` etc. |
|
||||
| Security key types / all-access key | `sqlWorkbenchUser` (`sqlWorkbenchAllAccess`), `sqlWorkbenchAudience` | `sql_workbench_user` (`sql_workbench_all_access`), `sql_workbench_audience` |
|
||||
| Joins | `sqlWorkbenchNotebookJoinSqlWorkbenchCell` etc. | `sql_workbench_notebook_join_sql_workbench_cell` etc. |
|
||||
|
||||
Unchanged: the `sqlWorkbench` app, the `sqlWorkbenchWidget` widget, the record-action process names, the REST routes, field names, and Java class names.
|
||||
|
||||
A `tableNamePrefix` (or QBit namespace) is now normalized to lowercase snake_case and joined with one `_`: `ns` and `ns_` give `ns_sql_workbench_notebook`, and `myNs` gives `my_ns_sql_workbench_notebook`. Prefixed tables follow the same rename.
|
||||
|
||||
The old names stay available as `@Deprecated` constants for migration scripts only: `SqlWorkbenchNotebook.LEGACY_TABLE_NAME` (and the same on each entity), `ShareAudienceType.LEGACY_NAME`, and `SqlWorkbenchQBitConfig.LEGACY_*`. They will be removed in 1.1.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Backend table names.** If your host mapped the old camelCase names to `sql_workbench_*` (for example with `inferBackendName`), nothing else is needed: the new names are already the backend names, and the mapping becomes a no-op. If your backend tables were really named after the camelCase QQQ names, rename them (`ALTER TABLE "sqlWorkbenchNotebook" RENAME TO sql_workbench_notebook;` and so on) or set the backend table names explicitly.
|
||||
2. **Permissions.** Grant the new names. If you set the permission names through the config getters (`getUserPermissionNames()`, `getSharePermissionNames()`, `getAdminPermissionNames()`), this is automatic. If roles live in an identity provider or a permissions table, rename them there. If you pinned the old names in config (`withAccessPermissionBaseName("sqlWorkbench")`), you can keep them, but the table permissions still change.
|
||||
3. **Security keys.** `SqlWorkbenchSecurity.applyToSession` sets the new key types. Custom code that put `sqlWorkbenchUser` or `sqlWorkbenchAudience` values in a session must use the new names (`config.getUserSecurityKeyTypeName()` and so on).
|
||||
4. **Stored references.** Rename the table names your app stored. A Postgres example, with the usual QQQ table and column names (adjust to your app; run inside one transaction, after a backup):
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
CREATE TEMP TABLE swb_rename (old_name TEXT, new_name TEXT) ON COMMIT DROP;
|
||||
INSERT INTO swb_rename VALUES
|
||||
('sqlWorkbenchNotebook', 'sql_workbench_notebook'),
|
||||
('sqlWorkbenchCell', 'sql_workbench_cell'),
|
||||
('sqlWorkbenchNotebookVersion', 'sql_workbench_notebook_version'),
|
||||
('sqlWorkbenchResultSnapshot', 'sql_workbench_result_snapshot'),
|
||||
('sqlWorkbenchNotebookShare', 'sql_workbench_notebook_share'),
|
||||
('sqlWorkbenchExecution', 'sql_workbench_execution');
|
||||
|
||||
-- audit: QQQ's audit_table holds the QQQ table name
|
||||
UPDATE audit_table a SET name = r.new_name FROM swb_rename r WHERE a.name = r.old_name;
|
||||
|
||||
-- saved views and saved reports
|
||||
UPDATE saved_view v SET table_name = r.new_name FROM swb_rename r WHERE v.table_name = r.old_name;
|
||||
UPDATE saved_report s SET table_name = r.new_name FROM swb_rename r WHERE s.table_name = r.old_name;
|
||||
|
||||
-- any app table that stores QQQ table names (for example a search index or a shared-record table)
|
||||
-- UPDATE quick_search_index q SET table_name = r.new_name FROM swb_rename r WHERE q.table_name = r.old_name;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
Saved views and reports can also name columns of joined tables (`sqlWorkbenchCell.title`) inside their JSON filter or column lists; search those columns for `sqlWorkbench` and replace the table part. Rebuild any search index whose document ids embed table names.
|
||||
5. **Frontends.** Links to `/app/sqlWorkbench/sqlWorkbenchNotebook/...` become `/app/sqlWorkbench/sql_workbench_notebook/...`.
|
||||
6. **Navigation and the notebook UI.** The app's home (`/app/sqlWorkbench`) is now a dashboard; the notebook editor moved to the Runner (`/app/sqlWorkbenchRunner`, `?notebook=<id>` opens one). Update bookmarks and links that expected the editor on the app page. The nav lists only Notebooks and the execution audit; if your host builds its own menus or nav tests, drop the cell, version, pinned-result and share tables from them (they are child lists on a notebook). Nothing to grant: the Runner and the dashboard use the app's access permission, and Activity by User the admin permission.
|
||||
7. **Optional: ESB and Quick Search.** Wire `SqlWorkbenchIntegrations.esbTableNames` and `quickSearchTables` into your configuration (README, "ESB and Quick Search"). Adding tables to Quick Search needs an index rebuild or a basepull for the existing rows.
|
||||
|
||||
## To 1.0.0-RC.2
|
||||
|
||||
See the "Upgrade from RC.1" section of [CHANGELOG.md](CHANGELOG.md).
|
||||
@@ -1,246 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
+30
-63
@@ -1,6 +1,6 @@
|
||||
# SQL Workbench QBit: design
|
||||
|
||||
Status: accepted for 1.0 (owner decisions 2026-10-11). Built against QQQ 4.1.0-RC.2 and Next UI 1.0.0-RC.11.
|
||||
Status: accepted for 1.0 (owner decisions 2026-10-11). Built against QQQ 4.1.0-RC.1 and Next UI 1.0.0-RC.11.
|
||||
|
||||
SQL Workbench is a super-user SQL notebook for any QQQ application. Users write SQL (and markdown) in notebook cells, run it against any RDBMS backend of the instance, and see typed, paged, exportable results, quick charts, and visual EXPLAIN plans. Sessions are **read-only by default**. A separate permission unlocks a **gated write mode** with per-run confirmation, a dry run, and an affected-row guard. **Every execution is audited.**
|
||||
|
||||
@@ -40,7 +40,7 @@ SQL Workbench is a super-user SQL notebook for any QQQ application. Users write
|
||||
┌──────────────────────────────────┐ ┌────────────────────────────────────────────┐
|
||||
│ /app/sqlWorkbench (AppHome) │ GET metaData │ QQQ middleware (Javalin 7) │
|
||||
│ └ WidgetRenderer │◄───────────────►│ /qqq/v1/metaData (app + widget, filtered │
|
||||
│ case 'SQL_WORKBENCH' │ │ by sql_workbench.hasAccess)│
|
||||
│ case 'SQL_WORKBENCH' │ │ by sqlWorkbench.hasAccess)│
|
||||
│ └ SqlWorkbenchWidget (lazy) │ │ │
|
||||
│ ├ NotebookList │ /qqq/v1/ │ SqlWorkbenchRouteProvider │
|
||||
│ ├ NotebookEditor │ sql-workbench/*│ └ SqlWorkbenchApi (session, perms, JSON) │
|
||||
@@ -64,8 +64,7 @@ SQL Workbench is a super-user SQL notebook for any QQQ application. Users write
|
||||
|---|---|
|
||||
| `SqlWorkbenchQBitConfig` | Host-facing config (§7). Implements `QBitConfig` with list-based, null-safe `validate`, `getDefaultBackendNameForTables`, and `getTableMetaDataCustomizer`. |
|
||||
| `SqlWorkbenchQBitProducer` | `QBitMetaDataProducer<SqlWorkbenchQBitConfig>`, the same pattern as Quick Search. The default `produce()` registers `QBitMetaData`, runs the component producers in `metadata/`, and stamps `sourceQBitName`. `postProduceActions` builds the `SqlWorkbenchRuntime` and registers the Javalin route provider (when Javalin is on the classpath). `produce(QInstance)` adds everything to the instance (Quick Search compat style). |
|
||||
| `metadata/*` | The 6 tables with their record locks, joins and child-list widgets, the two security key types, the record actions (Share Notebook, Duplicate Notebook, Restore Version: processes on tables, never in the nav), the `sqlWorkbenchWidget` widget (type `SQL_WORKBENCH`), and the `sqlWorkbench` app (widget plus all six tables). |
|
||||
| `customizers/*` | Table customizers that keep the notebook invariants for every entry point (notebook UI, table UI, `/qqq/v1` API): ownership, cell order and limits, versions, share keys, library visibility, append-only versions and audit, cascades. |
|
||||
| `metadata/*` | The 5 tables, the `sqlWorkbenchWidget` widget (type `SQL_WORKBENCH`), and the `sqlWorkbench` app (widget plus notebook and audit tables). No processes (owner rule). |
|
||||
| `SqlWorkbenchRuntime` | Instance-scoped live state, held transient on the config: result cache, running-statement registry, write-preview tokens, schema cache. |
|
||||
| `sql/*` | `SqlScanner` (dialect-aware lexer), `SqlClassifier` (split plus classify), `NamedParameterSql`. Pure Java, with no JDBC dependency. |
|
||||
| `engine/*` | `WorkbenchBackends` (target resolution and connection sourcing), `SqlExecutionEngine` (read-only, preview, commit), `SessionGuard` (per-vendor read-only, timeouts, cleanup), `ResultCollector` (typing and limits), `ResultCache`, `WritePreviewTokens`, `ExecutionAuditor`. |
|
||||
@@ -78,7 +77,7 @@ SQL Workbench is a super-user SQL notebook for any QQQ application. Users write
|
||||
|
||||
### 2.2 Frontend (Next UI)
|
||||
|
||||
The feature is advertised through **app presence**, the same as ESB. `/qqq/v1/metaData` publishes only an allow-list of supplemental metadata, so a flag would not reach the UI. The qbit's app `sqlWorkbench` carries one widget of type `SQL_WORKBENCH`. Both are gated by `sql_workbench.hasAccess`, so users without access never see either one. The generic `/app/[slug]` route renders `AppHome`, then `ConnectedWidget`, then `WidgetRenderer`, which gains:
|
||||
The feature is advertised through **app presence**, the same as ESB. `/qqq/v1/metaData` publishes only an allow-list of supplemental metadata, so a flag would not reach the UI. The qbit's app `sqlWorkbench` carries one widget of type `SQL_WORKBENCH`. Both are gated by `sqlWorkbench.hasAccess`, so users without access never see either one. The generic `/app/[slug]` route renders `AppHome`, then `ConnectedWidget`, then `WidgetRenderer`, which gains:
|
||||
|
||||
```tsx
|
||||
case 'SQL_WORKBENCH': return <SqlWorkbenchWidget widgetMetaData={widgetMetaData} /> // React.lazy
|
||||
@@ -105,20 +104,9 @@ Components (`src/components/sql-workbench/`):
|
||||
|
||||
## 3. Data model
|
||||
|
||||
Six tables on the host-chosen backend (`config.backendName`). They are standard QQQ tables in the `sqlWorkbench` app, with the usual query, record, edit, export, audit, and developer-mode screens, and record security locks decide who sees which rows (section 6).
|
||||
Five tables on the host-chosen backend (`config.backendName`). Names can be prefixed (`tableNamePrefix`) or customized (`tableMetaDataCustomizer`, for example snake_case backend names). The Postgres DDL is in [`docs/ddl/postgres.sql`](ddl/postgres.sql). Liquibase users can drop it into a formatted-SQL changeset.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
sql_workbench_notebook ||--o{ sql_workbench_cell : "cells (ordered)"
|
||||
sql_workbench_notebook ||--o{ sql_workbench_notebook_version : "versions (append-only)"
|
||||
sql_workbench_notebook ||--o{ sql_workbench_result_snapshot : "pinned results"
|
||||
sql_workbench_notebook ||--o{ sql_workbench_notebook_share : "shared with (read)"
|
||||
sql_workbench_execution }o..o| sql_workbench_notebook : "ran from (no FK)"
|
||||
```
|
||||
|
||||
Names can be prefixed (`tableNamePrefix`) or customized (`tableMetaDataCustomizer`, for example snake_case backend names). The Postgres DDL is in [`docs/ddl/postgres.sql`](ddl/postgres.sql). Liquibase users can drop it into a formatted-SQL changeset.
|
||||
|
||||
### `sql_workbench_notebook`
|
||||
### `sqlWorkbenchNotebook`
|
||||
| field | type | notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
@@ -126,48 +114,34 @@ erDiagram
|
||||
| description | TEXT | |
|
||||
| ownerUserId | STRING(255) | `QSession.user.idReference`, required |
|
||||
| ownerName | STRING(255) | display only |
|
||||
| visibility | STRING(16) | `PERSONAL` or `SHARED`; derived from the shares (`SHARED` while an `ALL` share exists), not editable |
|
||||
| visibility | STRING(16) | `PERSONAL` (default) or `SHARED` |
|
||||
| defaultBackendName | STRING(100) | |
|
||||
| parametersJson | TEXT | `[{"name","type","defaultValue","label"}]` |
|
||||
| currentVersion | INTEGER | incremented on every content change, from the notebook UI or the tables |
|
||||
| currentVersion | INTEGER | incremented on every save |
|
||||
| createDate, modifyDate | DATE_TIME | |
|
||||
|
||||
### `sql_workbench_cell`
|
||||
### `sqlWorkbenchCell`
|
||||
| field | type | notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
| notebookId | INTEGER | FK, PVS to notebook |
|
||||
| cellKey | STRING(64) | client-stable UUID; snapshots and audit refer to it |
|
||||
| sortOrder | INTEGER | cells added through the table go last |
|
||||
| sortOrder | INTEGER | |
|
||||
| cellType | STRING(16) | `SQL` or `MARKDOWN` |
|
||||
| title | STRING(255) | |
|
||||
| content | TEXT | SQL or markdown |
|
||||
| backendName | STRING(100) | null means the notebook default |
|
||||
| createDate, modifyDate | DATE_TIME | |
|
||||
|
||||
### `sql_workbench_notebook_version`
|
||||
Append-only history. Each save writes the full notebook plus cells as JSON: a notebook-UI save writes one version; a table edit of the notebook or one of its cells writes one version per change (`NotebookVersioning`, as the system user, after QQQ has authorized the change).
|
||||
### `sqlWorkbenchNotebookVersion`
|
||||
Append-only history. Each save writes the full notebook plus cells as JSON.
|
||||
`id, notebookId, versionNumber, contentJson (TEXT), savedByUserId, savedByName, comment, createDate`. Unique key: `(notebookId, versionNumber)`. Retention: `notebookVersionRetention` (default 100 per notebook, oldest pruned on save).
|
||||
|
||||
### `sql_workbench_result_snapshot`
|
||||
Pinned results. Created only by pinning a cached result in the notebook UI (so the data comes from a real run); through the table only `label` can change, and the owner can delete them.
|
||||
### `sqlWorkbenchResultSnapshot`
|
||||
Pinned results.
|
||||
`id, notebookId, cellKey, executionUuid, label, backendName, sqlText (TEXT), resultJson (TEXT: columns plus up to snapshotMaxRows rows), rowCount, truncated, createdByUserId, createDate`.
|
||||
|
||||
### `sql_workbench_notebook_share`
|
||||
Who, besides the owner, may read a notebook (read only; no shared editing).
|
||||
| field | type | notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
| notebookId | INTEGER | FK, PVS to notebook; cannot change |
|
||||
| audienceType | STRING(16) | `USER`, `GROUP`, or `ALL` (the shared library) |
|
||||
| audienceId | STRING(255) | the user id or group name; null for `ALL` |
|
||||
| audienceKey | STRING(300) | computed: `user:<id>`, `group:<name>` (lower-cased), or `all`; unique per notebook; what the share locks match |
|
||||
| createdByUserId, createdByName | STRING(255) | |
|
||||
| createDate, modifyDate | DATE_TIME | |
|
||||
|
||||
Publishing in the notebook UI inserts (or deletes) the `ALL` share; the share customizer keeps the notebook's `visibility` in step.
|
||||
|
||||
### `sql_workbench_execution` (audit, append-only)
|
||||
### `sqlWorkbenchExecution` (audit, append-only)
|
||||
| field | type | notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
@@ -190,17 +164,17 @@ Publishing in the notebook UI inserts (or deletes) the `ALL` share; the share cu
|
||||
| clientIp | STRING(64) | from the request, when available |
|
||||
| createDate | DATE_TIME | |
|
||||
|
||||
The audit table has its insert, edit, and delete capabilities disabled in the QQQ metadata, so the UI and generic API do not offer those operations, and its customizer rejects them in the core actions for every session but the system user (admins included). Its record lock shows each user their own rows (admins all). The engine writes it through QQQ actions with a system session context. The row is inserted **before** execution (`RUNNING`). If that insert fails, the SQL **does not run** (fail closed). The row is updated at the end. Retention: `auditRetentionDays` (default unlimited; the host prunes).
|
||||
The audit table has its insert, edit, and delete capabilities disabled in the QQQ metadata, so the UI and generic API cannot change it. The engine writes it through QQQ actions with a system session context. The row is inserted **before** execution (`RUNNING`). If that insert fails, the SQL **does not run** (fail closed). The row is updated at the end. Retention: `auditRetentionDays` (default unlimited; the host prunes).
|
||||
|
||||
---
|
||||
|
||||
## 4. Execution engine
|
||||
|
||||
### 4.1 Pipeline (every run)
|
||||
1. **Authorize.** The session must have `sql_workbench.hasAccess` (403 otherwise). Write and EXPLAIN ANALYZE also need `sql_workbench.write` and `config.writeModeEnabled`.
|
||||
1. **Authorize.** The session must have `sqlWorkbench.hasAccess` (403 otherwise). Write and EXPLAIN ANALYZE also need `sqlWorkbench.write` and `config.writeModeEnabled`.
|
||||
2. **Resolve the target.** The backend must be an `RDBMSBackendMetaData` in the instance and allowed by `allowedBackendNames` / `excludedBackendNames` (404 otherwise). The dialect comes from the vendor.
|
||||
3. **Validate.** Check the SQL length (`maxSqlLength`, default 100 KB), then split and classify with `SqlClassifier`. Rules:
|
||||
- `FORBIDDEN` statements are rejected in every mode. On MySQL this includes any statement with an executable comment (`/*! ... */`, `/*M! ... */`) or optimizer hint (`/*+ ... */`): MySQL runs their contents, so the scanner keeps them as tokens instead of skipping them, and fails closed. MySQL `--` comments need trailing whitespace (`1--1` is arithmetic).
|
||||
- `FORBIDDEN` statements are rejected in every mode.
|
||||
- Read-only mode allows exactly **one** statement, of kind `READ`.
|
||||
- Write mode allows up to `maxStatementsPerRun` (default 50).
|
||||
- Every `:param` must have a value. Values are bound through `PreparedStatement`, never spliced into the SQL.
|
||||
@@ -228,12 +202,10 @@ Per-vendor setup (all of it transaction-scoped, or reverted in `finally`):
|
||||
|
||||
There is no session-level `SET` leakage: user SQL can never run `SET` / `RESET` / `USE` / `PRAGMA x=y` / transaction control (all `FORBIDDEN`), and the engine's own settings are `SET LOCAL` or reverted. If any cleanup step fails, the engine calls `connection.abort()` (falling back to `close()`) so a dirty connection is never reused, and logs a warning (without the SQL).
|
||||
|
||||
> **Production: configure `readOnlyCredentials` for every target backend.** Use a dedicated database role with `SELECT` grants only: no write privileges, not a superuser, no `pg_signal_backend` (or MySQL `PROCESS` / `SUPER` / `CONNECTION_ADMIN`). Without it, read-only mode connects as the application's own role and relies on the classifier and the session's `READ ONLY` transaction alone. On H2 / generic backends there is no enforced read-only transaction, so the classifier is the only control. On Postgres a `READ ONLY` transaction still lets the application role call side-effecting functions such as `pg_terminate_backend()` or `pg_cancel_backend()`. `/capabilities` reports `readOnlyCredentials: false` / `readOnlyEnforced: false` per backend so you can check.
|
||||
|
||||
> **Defense in depth.** The READ ONLY transaction is the primary control on Postgres, MySQL, and SQLite. On H2 the classifier is the only control, so production should use Postgres or MySQL with `readOnlyCredentials` (a role with `SELECT` grants only). A READ ONLY transaction does not stop side-effecting *functions* that are not writes, such as `pg_terminate_backend()` or `pg_sleep()`. Only a least-privilege role does. That is why `readOnlyCredentials` is recommended for production.
|
||||
|
||||
### 4.3 Write mode
|
||||
Write mode needs `sql_workbench.write`. It is a two-step protocol:
|
||||
Write mode needs `sqlWorkbench.write`. It is a two-step protocol:
|
||||
|
||||
1. **Preview**: `POST /execute {mode: "WRITE"}` with no `confirmToken`.
|
||||
- Classify the statements and reject `FORBIDDEN` ones.
|
||||
@@ -302,7 +274,7 @@ The response is `{executionUuid, dialect, analyze, plan: PlanNode, raw: string}`
|
||||
|
||||
SQL errors are results, not HTTP errors: `/execute` and `/explain` return 200 with `status` FAILED, TIMED_OUT, or CANCELLED plus `error` and `sqlState`.
|
||||
|
||||
All endpoints need an authenticated QQQ session (cookie or Authorization header, via `ExecutorSessionUtils.setupSession`) and `sql_workbench.hasAccess`. Errors are `{"error": "..."}` with 400, 401, 403, 404, 409 (a re-used `executionUuid`), or 500, through `QJavalinUtils.handleException`. Execution UUIDs are case-insensitive on every path (`/execute`, `/executions/{uuid}/rows`, `/export`, `/cancel`, snapshots); the server stores and returns them lower-case. Bodies are JSON. Request and response bodies are never logged.
|
||||
All endpoints need an authenticated QQQ session (cookie or Authorization header, via `ExecutorSessionUtils.setupSession`) and `sqlWorkbench.hasAccess`. Errors are `{"error": "..."}` with 400, 401, 403, 404, or 500, through `QJavalinUtils.handleException`. Bodies are JSON. Request and response bodies are never logged.
|
||||
|
||||
```yaml
|
||||
GET /capabilities
|
||||
@@ -314,9 +286,7 @@ GET /capabilities
|
||||
GET /schema/{backendName}?refresh=bool -> Schema (see 4.6)
|
||||
POST /execute
|
||||
body: { backendName, sql, params?: {name: value}, mode?: READ_ONLY|WRITE, notebookId?, cellKey?,
|
||||
pageSize?, confirmToken?, acknowledgeRowGuard?,
|
||||
executionUuid? } # optional client-chosen UUID, so the client can cancel before the response;
|
||||
# single-use: an id that is running or already audited gets 409
|
||||
pageSize?, confirmToken?, acknowledgeRowGuard? }
|
||||
200 (READ_ONLY / commit): { executionUuid, status, mode, statements: [{index, kind, leadingKeyword, affectedRows?}],
|
||||
columns: [Column], rows: [[...]], rowCount, hasMore, truncated, truncatedReason?, durationMs,
|
||||
rowsAffected? }
|
||||
@@ -334,7 +304,7 @@ POST /notebooks body: Notebook -> Notebook (version 1)
|
||||
GET /notebooks/{id} -> Notebook (with cells, canEdit)
|
||||
PUT /notebooks/{id} body: Notebook -> Notebook (cells replaced, new version)
|
||||
DELETE /notebooks/{id} -> { deleted: true }
|
||||
POST /notebooks/{id}/publish body: { shared: bool } (needs sql_workbench.share)
|
||||
POST /notebooks/{id}/publish body: { shared: bool } (needs sqlWorkbench.share)
|
||||
POST /notebooks/{id}/duplicate -> Notebook (personal copy)
|
||||
GET /notebooks/{id}/versions -> { versions: [{versionNumber, savedByName, comment, createDate}] }
|
||||
GET /notebooks/{id}/versions/{n} -> { versionNumber, notebook: Notebook }
|
||||
@@ -349,7 +319,7 @@ DELETE /notebooks/{id}/snapshots/{snapshotId}
|
||||
Visibility rules:
|
||||
- A user can read their own notebooks and all `SHARED` ones.
|
||||
- Only the owner can edit, delete, snapshot, or restore.
|
||||
- Publishing or unpublishing needs `sql_workbench.share` **and** ownership.
|
||||
- Publishing or unpublishing needs `sqlWorkbench.share` **and** ownership.
|
||||
- Anyone with access can duplicate a readable notebook.
|
||||
|
||||
---
|
||||
@@ -358,7 +328,7 @@ Visibility rules:
|
||||
|
||||
| Concern | Control |
|
||||
|---|---|
|
||||
| Who | Deny by default. Every endpoint needs `sql_workbench.hasAccess` (the app and widget permission base name `sqlWorkbench`, at `HAS_ACCESS_PERMISSION`). Write mode and EXPLAIN ANALYZE need `sql_workbench.write`. Sharing (including publishing to the library) needs `sql_workbench.share` plus insert/edit/delete on the share table. The tables use QQQ's per-table `READ_INSERT_EDIT_DELETE_PERMISSIONS`; `SqlWorkbenchQBitConfig.getUserPermissionNames / getSharePermissionNames / getAdminPermissionNames` list what to grant per role. `sql_workbench_admin.hasAccess` grants the all-access key. The names are configurable. |
|
||||
| Who | Deny by default. Every endpoint needs `sqlWorkbench.hasAccess` (the app, widget, and table permission base name `sqlWorkbench`, at `HAS_ACCESS_PERMISSION`). Write mode and EXPLAIN ANALYZE need `sqlWorkbench.write`. Publishing to the library needs `sqlWorkbench.share`. The names are configurable. |
|
||||
| What can run | Read-only transactions by default (§4.2); classifier as defense in depth; `FORBIDDEN` statements in every mode; one statement per read-only run. |
|
||||
| Writes | Separate permission, two-step preview then commit with a server-side single-use token bound to the user, SQL, and backend, a dry run with rollback, a row guard with acknowledgement, a hard cap, a drift check, and a statement timeout. |
|
||||
| Credentials | Never returned or logged. Backend metadata goes out only as `{name, label, vendor, dialect}`. `readOnlyCredentials` support `${env.X}` / `${prop.X}` interpolation (`QMetaDataVariableInterpreter`), and the config's `toString` redacts them. |
|
||||
@@ -366,9 +336,7 @@ Visibility rules:
|
||||
| Session and CSRF | QQQ middleware session handling. The cookie session is `HttpOnly`; there is no extra CSRF token, matching the rest of `/qqq/v1` (the JSON-only POSTs need `Content-Type: application/json`, which a cross-site form cannot send without a CORS preflight). The route provider rejects non-JSON POST bodies. |
|
||||
| Isolation between users | Result cache, cancellation, history, and write tokens are keyed by the user's `idReference`. Another user's execution UUID returns 404. |
|
||||
| QQQ semantics | **Raw SQL bypasses QQQ**: no record security locks, table/field permissions, hidden fields, audit of record changes, or customizers/automations. Rows changed in write mode do **not** trigger QQQ audits, record-change listeners, ESB events, or Quick Search indexing. This is documented in the UI (a banner in write mode) and in the README. Use QQQ processes for business changes. |
|
||||
| Which rows | Record security locks, enforced by QQQ's core actions and therefore for every entry point (table UI, `/qqq/v1` table API, exports, PVS lookups, processes, the workbench endpoints). Two key types: `sql_workbench_user` (the session's user id; all-access key `sql_workbench_all_access` for admins) and `sql_workbench_audience` (`user:<id>`, `group:<name>` per group, `all`). A notebook's lock is OR(owner lock on `ownerUserId`, read-scoped share lock on `sql_workbench_notebook_share.audienceKey` through the share-to-notebook join); its cells, versions and pinned results lock through their notebook (one and two joins); a share row is readable by the notebook's owner or the audience it names; an execution by its `userId`. Writes evaluate only the write-scoped locks, so being shared with is read-only. The RDBMS module de-duplicates rows that match through several shares (`SELECT DISTINCT`, `COUNT(DISTINCT)`). Hosts call `SqlWorkbenchSecurity.applyToSession` from their authentication customizer; sessions without the access permission get no keys and see no rows. |
|
||||
| Invariants | Table customizers, shared by the notebook UI and the tables: the owner is the inserting session (only the system user may insert for someone else); `visibility`, `currentVersion` and dates are system-managed; cells are validated (type, length, key) and appended in order, never move notebooks, and every change writes a version; shares compute `audienceKey`, need the share permission, and keep `visibility` in step; pinned results are only created by pinning and only relabeled; versions and the audit are append-only for everyone but the system user. `NotebookService` runs as the caller through the same actions (flagging a whole-notebook save so it is versioned once) and keeps its explicit owner / share checks as defense in depth; only version bookkeeping and cascades run as the system user, after QQQ authorized the change. |
|
||||
| QQQ dependency | Partial updates of child rows (a cell's content, a pinned result's label) pass the joined write locks only with QQQ `4.1.0-RC.4`+ (qqq#1073: locks are validated against the stored row overlaid with the written values). |
|
||||
| Personal vs shared | "Personal" is a UX scope, not a confidentiality boundary between super-users. Notebook tables are visible in the QQQ table UI to anyone with `sqlWorkbench.hasAccess`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -386,9 +354,8 @@ Visibility rules:
|
||||
| `useBackendReadOnlyReplica` | true | use the backend's `readOnlyBackendMetaData` for read-only sessions |
|
||||
| `writeModeEnabled` | true | a global kill switch for write mode |
|
||||
| `accessPermissionBaseName` | `sqlWorkbench` | `<base>.hasAccess` |
|
||||
| `writePermissionName` | `sql_workbench.write` | |
|
||||
| `sharePermissionName` | `sql_workbench.share` | |
|
||||
| `adminPermissionBaseName` | `sql_workbench_admin` | `<base>.hasAccess` grants the all-access security key (every user's rows); must differ from `accessPermissionBaseName` |
|
||||
| `writePermissionName` | `sqlWorkbench.write` | |
|
||||
| `sharePermissionName` | `sqlWorkbench.share` | |
|
||||
| `statementTimeoutSeconds` | 30 | read-only and EXPLAIN |
|
||||
| `writeStatementTimeoutSeconds` | 60 | preview and commit |
|
||||
| `maxRows` | 10000 | rows kept per result |
|
||||
@@ -432,7 +399,7 @@ Visibility rules:
|
||||
│ │ + SQL cell + Markdown cell │
|
||||
└────────────────────────┴───────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Write mode (user has sql_workbench.write; toggled per cell):
|
||||
Write mode (user has sqlWorkbench.write; toggled per cell):
|
||||
┌ Confirm write on "postgres" ───────────────────────────────────────────┐
|
||||
│ ⚠ Raw SQL bypasses QQQ security, audits, automations and indexing. │
|
||||
│ 1. UPDATE UPDATE web_user SET active = false WHERE … 1,204 rows │
|
||||
@@ -500,7 +467,7 @@ Plan tab:
|
||||
| 5 (later) | Saved-query sharing beyond the library, scheduled notebooks, per-backend permissions (`sqlWorkbench.backend.<name>`), result diffing, a core QQQ `availablePermissions` hook for `write` / `share` | backlog |
|
||||
|
||||
## 11. Open questions
|
||||
1. **Permission discovery.** `sql_workbench.write` and `sql_workbench.share` are plain session permissions. QQQ 4.1 lists available permissions only from tables, processes, apps, reports, and widgets, so these two do not appear in role-management UIs (for example qbit-user-role-permissions). Options: a core hook for custom permissions, or hidden marker widgets. Deferred.
|
||||
2. **Per-backend authorization.** Should write access be limited per backend (for example `sql_workbench.write.<backend>`)? 1.0 uses one global write permission (the owner will refine later).
|
||||
1. **Permission discovery.** `sqlWorkbench.write` and `sqlWorkbench.share` are plain session permissions. QQQ 4.1 lists available permissions only from tables, processes, apps, reports, and widgets, so these two do not appear in role-management UIs (for example qbit-user-role-permissions). Options: a core hook for custom permissions, or hidden marker widgets. Deferred.
|
||||
2. **Per-backend authorization.** Should write access be limited per backend (for example `sqlWorkbench.write.<backend>`)? 1.0 uses one global write permission (the owner will refine later).
|
||||
3. **Audit retention and redaction.** The audit stores parameter values verbatim. Should hosts be able to redact, for example, `password`-like parameter names? 1.0 stores them as is.
|
||||
4. **H2 in production.** H2 has no enforced read-only transaction. Should the workbench refuse H2 targets in production unless read-only credentials are configured? 1.0 allows it, with a warning in capabilities (`readOnlyEnforced: false`).
|
||||
@@ -1,135 +0,0 @@
|
||||
-- SQL Workbench QBit (com.kingsrook.qbits:qbit-sql-workbench) - PostgreSQL DDL
|
||||
--
|
||||
-- Table and column names are the snake_case forms of the QQQ names, which is
|
||||
-- what a host gets when its tableMetaDataCustomizer applies
|
||||
-- QInstanceEnricher.inferBackendName to the table name and
|
||||
-- QInstanceEnricher.setInferredFieldBackendNames to the fields (see README,
|
||||
-- "Storage"). With a tableNamePrefix, prefix the table names the same way.
|
||||
--
|
||||
-- Liquibase: paste into a formatted-SQL changeset, e.g.
|
||||
-- -- liquibase formatted sql
|
||||
-- -- changeset you:NNNN-1
|
||||
-- Idempotent (IF NOT EXISTS), so it is safe on re-run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
owner_user_id VARCHAR(255) NOT NULL,
|
||||
owner_name VARCHAR(255),
|
||||
visibility VARCHAR(16) NOT NULL DEFAULT 'PERSONAL',
|
||||
default_backend_name VARCHAR(100),
|
||||
parameters_json TEXT,
|
||||
current_version INTEGER,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_notebook PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_owner ON sql_workbench_notebook (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_visibility ON sql_workbench_notebook (visibility);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_cell (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
cell_key VARCHAR(64) NOT NULL,
|
||||
sort_order INTEGER,
|
||||
cell_type VARCHAR(16) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
content TEXT,
|
||||
backend_name VARCHAR(100),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_cell PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_cell_key UNIQUE (notebook_id, cell_key),
|
||||
CONSTRAINT fk_sql_workbench_cell_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook_version (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
content_json TEXT,
|
||||
saved_by_user_id VARCHAR(255),
|
||||
saved_by_name VARCHAR(255),
|
||||
comment VARCHAR(255),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_notebook_version PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_notebook_version UNIQUE (notebook_id, version_number),
|
||||
CONSTRAINT fk_sql_workbench_notebook_version_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_result_snapshot (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
cell_key VARCHAR(64),
|
||||
execution_uuid VARCHAR(36),
|
||||
label VARCHAR(255),
|
||||
backend_name VARCHAR(100),
|
||||
sql_text TEXT,
|
||||
result_json TEXT,
|
||||
row_count INTEGER,
|
||||
truncated BOOLEAN,
|
||||
created_by_user_id VARCHAR(255),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_result_snapshot PRIMARY KEY (id),
|
||||
CONSTRAINT fk_sql_workbench_result_snapshot_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
-- Shares (1.0.0-RC.2): who, besides the owner, may read a notebook.
|
||||
-- audience_key is "user:<id>", "group:<name>" or "all" (the shared library);
|
||||
-- the record security locks match it against the session's audience keys.
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook_share (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
audience_type VARCHAR(16) NOT NULL,
|
||||
audience_id VARCHAR(255),
|
||||
audience_key VARCHAR(300) NOT NULL,
|
||||
created_by_user_id VARCHAR(255),
|
||||
created_by_name VARCHAR(255),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_notebook_share PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_notebook_share UNIQUE (notebook_id, audience_key),
|
||||
CONSTRAINT fk_sql_workbench_notebook_share_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_share_audience ON sql_workbench_notebook_share (audience_key, notebook_id);
|
||||
|
||||
-- Upgrading from 1.0.0-RC.1: notebooks published to the library
|
||||
-- (visibility SHARED) get their "all" share row. Idempotent.
|
||||
INSERT INTO sql_workbench_notebook_share (notebook_id, audience_type, audience_id, audience_key, created_by_user_id, created_by_name, create_date, modify_date)
|
||||
SELECT n.id, 'ALL', NULL, 'all', n.owner_user_id, n.owner_name, COALESCE(n.modify_date, CURRENT_TIMESTAMP), COALESCE(n.modify_date, CURRENT_TIMESTAMP)
|
||||
FROM sql_workbench_notebook n
|
||||
WHERE n.visibility = 'SHARED'
|
||||
AND NOT EXISTS (SELECT 1 FROM sql_workbench_notebook_share s WHERE s.notebook_id = n.id AND s.audience_key = 'all');
|
||||
|
||||
-- Audit: append-only. Not foreign-keyed to notebooks, so deleting a
|
||||
-- notebook never deletes its audit trail.
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_execution (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
execution_uuid VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(255),
|
||||
user_name VARCHAR(255),
|
||||
backend_name VARCHAR(100),
|
||||
mode VARCHAR(24),
|
||||
status VARCHAR(16),
|
||||
sql_text TEXT,
|
||||
sql_hash VARCHAR(64),
|
||||
parameters_json TEXT,
|
||||
statement_count INTEGER,
|
||||
statement_kinds VARCHAR(255),
|
||||
notebook_id INTEGER,
|
||||
cell_key VARCHAR(64),
|
||||
start_time TIMESTAMP WITHOUT TIME ZONE,
|
||||
end_time TIMESTAMP WITHOUT TIME ZONE,
|
||||
duration_ms INTEGER,
|
||||
rows_returned INTEGER,
|
||||
rows_affected INTEGER,
|
||||
truncated BOOLEAN,
|
||||
error_message TEXT,
|
||||
client_ip VARCHAR(64),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_execution PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_execution_uuid UNIQUE (execution_uuid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_execution_user ON sql_workbench_execution (user_id, id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_execution_start ON sql_workbench_execution (start_time);
|
||||
@@ -1,295 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,189 +0,0 @@
|
||||
<# : 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,290 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<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>1.0.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<postgres.test.image>postgres:16-alpine</postgres.test.image>
|
||||
<testcontainers.version>1.21.4</testcontainers.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-sql-workbench</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit SQL Workbench</name>
|
||||
<description>QBit providing a super-user SQL notebook (read-only by default, gated write mode, audited) over any QQQ RDBMS backend</description>
|
||||
<url>https://github.com/QRun-IO/qbit-sql-workbench</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-sql-workbench.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-sql-workbench.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-sql-workbench</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Connection management and backend meta-data for the RDBMS targets. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- The REST routes (/qqq/v1/sql-workbench/*). Hosts that serve QQQ through -->
|
||||
<!-- Javalin already have it; others can still use the engine directly. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.2.220</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-sqlite</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.8.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Testcontainers for the Postgres integration tests -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>sql-workbench.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>sql-workbench.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- Exclude integration tests from unit test run -->
|
||||
<excludes>
|
||||
<exclude>**/*IntegrationTest.java</exclude>
|
||||
<exclude>**/*IT.java</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<!-- Run integration tests with failsafe -->
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<postgres.test.image>postgres:16-alpine</postgres.test.image>
|
||||
</systemPropertyVariables>
|
||||
<includes>
|
||||
<include>**/*IntegrationTest.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer, runtime services, Javalin 7 -->
|
||||
<!-- routes; the record developer panel contract needs 4.1.0-RC.4). Until -->
|
||||
<!-- qbit-build-parent 2.1.0 imports the released 4.1 BOM, this profile is -->
|
||||
<!-- active by default and imports qqq-bom-pom 4.1.0-RC.4 ahead of the -->
|
||||
<!-- parent's 4.0.0 BOM (same as qbit-quick-search, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</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>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>sonatype-snapshots</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dedicated database credentials the workbench uses for one target backend,
|
||||
** instead of the application's own (for example a role with SELECT grants
|
||||
** only, for read-only sessions). Defence in depth: a read-only transaction
|
||||
** does not stop side-effecting functions, a least-privilege role does.
|
||||
**
|
||||
** username and password may be ${env.X} / ${prop.X} references; they are
|
||||
** resolved only when a connection is opened. jdbcUrl is optional (default:
|
||||
** the backend's own URL). Never serialized; toString redacts.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCredentials implements Serializable
|
||||
{
|
||||
private String username;
|
||||
private String password;
|
||||
private String jdbcUrl;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Redacting toString.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("SqlWorkbenchCredentials{username=" + (username == null ? "null" : "***") + ", password=***, jdbcUrl=" + (jdbcUrl == null ? "default" : "custom") + "}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for username
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getUsername()
|
||||
{
|
||||
return (this.username);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for username
|
||||
*******************************************************************************/
|
||||
public void setUsername(String username)
|
||||
{
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for username
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCredentials withUsername(String username)
|
||||
{
|
||||
this.username = username;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for password
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getPassword()
|
||||
{
|
||||
return (this.password);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for password
|
||||
*******************************************************************************/
|
||||
public void setPassword(String password)
|
||||
{
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for password
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCredentials withPassword(String password)
|
||||
{
|
||||
this.password = password;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for jdbcUrl. Ignored by JSON: a URL can carry a password.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getJdbcUrl()
|
||||
{
|
||||
return (this.jdbcUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for jdbcUrl
|
||||
*******************************************************************************/
|
||||
public void setJdbcUrl(String jdbcUrl)
|
||||
{
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for jdbcUrl
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCredentials withJdbcUrl(String jdbcUrl)
|
||||
{
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-in hooks for wiring the workbench's main tables into a host's ESB and
|
||||
** Quick Search, without the qbit depending on either module: the host reads
|
||||
** these names and field lists and passes them to its own qqq-esb
|
||||
** (EsbTableMetaData publications) and qbit-quick-search
|
||||
** (SearchableTableConfig) configuration.
|
||||
**
|
||||
** The main tables are Notebooks and the Execution audit (the tables in the
|
||||
** app's nav). The sub-tables (cells, versions, pinned results, shares) are
|
||||
** reached through a notebook and are left out on purpose.
|
||||
**
|
||||
** What leaves the database: a notebook event carries the notebook row
|
||||
** (title, description, owner, visibility, default backend, parameter
|
||||
** definitions). An execution event carries the audit row, including the SQL
|
||||
** text and the parameter values; results are never stored, so they are
|
||||
** never published or indexed. Search hits are re-read through the tables'
|
||||
** record locks, so a user only finds what they could open.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchIntegrations
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A table for Quick Search: its fields with weights (most important
|
||||
** first), the field basepull compares, and the label of a hit.
|
||||
**
|
||||
** @param tableName the (prefixed) table name
|
||||
** @param fieldWeights searchable field names and their weights
|
||||
** @param basepullTimestamp field whose value moves when a row changes
|
||||
** @param recordLabelFormat format of a hit's label
|
||||
** @param recordLabelFields fields for recordLabelFormat
|
||||
*******************************************************************************/
|
||||
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchIntegrations()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The main tables (in nav order), with the config's prefix applied.
|
||||
*******************************************************************************/
|
||||
public static List<String> mainTableNames(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
List<String> names = new ArrayList<>();
|
||||
for(String tableName : SqlWorkbenchAppMetaDataProducer.MAIN_TABLES)
|
||||
{
|
||||
names.add(config.applyPrefix(tableName));
|
||||
}
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables whose insert / update / delete events a host may publish to
|
||||
** its ESB (the main tables). Executions are inserted when a run starts and
|
||||
** updated when it ends.
|
||||
*******************************************************************************/
|
||||
public static List<String> esbTableNames(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
return (mainTableNames(config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The main tables as Quick Search tables:
|
||||
** - Notebooks: title, description, owner, default backend; basepull on
|
||||
** modifyDate (cell edits bump it, as each one saves a new version).
|
||||
** - Executions: SQL text, user, execution UUID, error, backend; basepull
|
||||
** on createDate (the audit has no modify date; real-time indexing picks
|
||||
** up the end-of-run update, and the reconcile catches anything missed).
|
||||
*******************************************************************************/
|
||||
public static List<SearchableTable> quickSearchTables(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
Map<String, Integer> notebook = new LinkedHashMap<>();
|
||||
notebook.put("title", 10);
|
||||
notebook.put("description", 5);
|
||||
notebook.put("ownerName", 3);
|
||||
notebook.put("defaultBackendName", 1);
|
||||
|
||||
Map<String, Integer> execution = new LinkedHashMap<>();
|
||||
execution.put("sqlText", 8);
|
||||
execution.put("executionUuid", 5);
|
||||
execution.put("userName", 3);
|
||||
execution.put("errorMessage", 2);
|
||||
execution.put("backendName", 1);
|
||||
|
||||
return (List.of(
|
||||
new SearchableTable(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME), notebook, "modifyDate", "%s", List.of("title")),
|
||||
new SearchableTable(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), execution, "createDate", "SQL run by %s (%s)", List.of("userName", "mode"))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Gates a record action on one named session permission (for example the
|
||||
** share table's "insert" permission), and lists that same name among the
|
||||
** instance's available permissions. Unlike QQQ's
|
||||
** UseTablePermissionCustomPermissionChecker it needs no QContext instance
|
||||
** when permissions are listed.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
|
||||
{
|
||||
public static final String PERMISSION_PROPERTY = "permissionName";
|
||||
|
||||
private String permissionName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A code reference that requires the given permission.
|
||||
*******************************************************************************/
|
||||
public static QCodeReference requiring(String permissionName)
|
||||
{
|
||||
return (new QCodeReferenceWithProperties(SqlWorkbenchPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
|
||||
{
|
||||
this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deny unless the session holds the permission (fail closed when it is
|
||||
** not configured).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(permissionName == null || session == null || !session.hasPermission(permissionName))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean handlesBuildAvailablePermission()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission is already listed for its table; nothing extra to list.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,269 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchTableMetaDataHelper;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point of the SQL Workbench QBit, a QQQ 4.1 QBitMetaDataProducer.
|
||||
**
|
||||
** The framework's default produce() registers QBitMetaData
|
||||
** (com.kingsrook.qbits:sql-workbench), validates the config, runs the
|
||||
** component producers in the metadata package (five tables, notebook PVS,
|
||||
** SQL_WORKBENCH widget, sqlWorkbench app), applies the config's table
|
||||
** customizer and default backend, stamps sourceQBitName, then calls
|
||||
** {@link #postProduceActions}, which builds the runtime and registers the
|
||||
** /qqq/v1/sql-workbench Javalin routes.
|
||||
**
|
||||
** Host usage:
|
||||
** new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkbenchQBitConfig>
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SqlWorkbenchQBitProducer.class);
|
||||
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "sql-workbench";
|
||||
public static final String APP_NAME = SqlWorkbenchAppMetaDataProducer.APP_NAME;
|
||||
|
||||
private static final String JAVALIN_CLASS = "com.kingsrook.qqq.middleware.javalin.QJavalinMetaData";
|
||||
private static final String VERSION = loadVersion();
|
||||
|
||||
private SqlWorkbenchQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchQBitProducer withQBitConfig(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for the config.
|
||||
*******************************************************************************/
|
||||
public void setQBitConfig(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SqlWorkbenchQBitConfig getQBitConfig()
|
||||
{
|
||||
return (this.qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespace for this QBit instance; also the default tableNamePrefix.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (this.namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(getNamespace())
|
||||
.withConfig(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Component producers live in the metadata package only (the api package
|
||||
** needs qqq-middleware-javalin, which is optional).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPackageNameForFindingMetaDataProducers()
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.class.getPackageName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A producer found by package scanning has no config; skip it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce and add everything to the instance. Returns an empty output so a
|
||||
** host that also calls addSelfToInstance on the result adds nothing twice.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
if(qBitConfig == null)
|
||||
{
|
||||
throw (new QException("SqlWorkbenchQBit configuration validation failed: no config was supplied (call withQBitConfig)"));
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(namespace) && !StringUtils.hasContent(qBitConfig.getTableNamePrefix()))
|
||||
{
|
||||
qBitConfig.setTableNamePrefix(namespace);
|
||||
}
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException("SqlWorkbenchQBit configuration validation failed: " + String.join("; ", errors)));
|
||||
}
|
||||
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the runtime and register the REST routes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void postProduceActions(MetaDataProducerMultiOutput output, QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchRuntime previous = qBitConfig.getRuntime();
|
||||
if(previous != null)
|
||||
{
|
||||
previous.close();
|
||||
}
|
||||
qBitConfig.setRuntime(new SqlWorkbenchRuntime(qBitConfig));
|
||||
|
||||
boolean routes = false;
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchRoutes.register(qInstance);
|
||||
routes = true;
|
||||
}
|
||||
|
||||
LOG.info("SQL Workbench QBit produced", logPair("version", VERSION), logPair("backendName", qBitConfig.getBackendName()),
|
||||
logPair("routes", routes), logPair("writeModeEnabled", qBitConfig.getWriteModeEnabled()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static boolean isJavalinAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(JAVALIN_CLASS, false, SqlWorkbenchQBitProducer.class.getClassLoader());
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The artifact version, from the Maven-filtered sql-workbench.properties.
|
||||
*******************************************************************************/
|
||||
static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = SqlWorkbenchQBitProducer.class.getClassLoader().getResourceAsStream("sql-workbench.properties"))
|
||||
{
|
||||
if(inputStream != null)
|
||||
{
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(StringUtils.hasContent(version) && !version.contains("${"))
|
||||
{
|
||||
return (version);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Could not read sql-workbench.properties", e);
|
||||
}
|
||||
return ("unknown");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the artifact version.
|
||||
*******************************************************************************/
|
||||
public static String getVersion()
|
||||
{
|
||||
return (VERSION);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.RunningStatements;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WritePreviewTokens;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance-scoped live state of the SQL Workbench: result cache, running
|
||||
** statements, write-preview tokens, schema cache, and the derived backend
|
||||
** meta-data used for dedicated credentials. Held (transient) on the config;
|
||||
** resolved from the QContext's instance with {@link #get()}.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRuntime
|
||||
{
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
private final Clock clock;
|
||||
private final ResultCache resultCache;
|
||||
private final RunningStatements runningStatements = new RunningStatements();
|
||||
private final WritePreviewTokens writePreviewTokens;
|
||||
|
||||
private final Map<String, CachedSchema> schemaCache = new ConcurrentHashMap<>();
|
||||
private final Map<String, RDBMSBackendMetaData> derivedBackends = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A cached schema description.
|
||||
**
|
||||
** @param schema the response body
|
||||
** @param cachedAt when it was read
|
||||
*******************************************************************************/
|
||||
public record CachedSchema(Map<String, Object> schema, Instant cachedAt)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchRuntime(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this(config, Clock.systemUTC());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchRuntime(SqlWorkbenchQBitConfig config, Clock clock)
|
||||
{
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
this.resultCache = new ResultCache(config.getResultCacheMaxEntries(), config.getResultCacheTtlSeconds(), clock);
|
||||
this.writePreviewTokens = new WritePreviewTokens(config.getWriteConfirmTtlSeconds(), clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime of the SQL Workbench qbit in the QContext's instance.
|
||||
*******************************************************************************/
|
||||
public static SqlWorkbenchRuntime get() throws QNotFoundException
|
||||
{
|
||||
return (get(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime of the SQL Workbench qbit in an instance.
|
||||
*******************************************************************************/
|
||||
public static SqlWorkbenchRuntime get(QInstance qInstance) throws QNotFoundException
|
||||
{
|
||||
if(qInstance != null && qInstance.getQBits() != null)
|
||||
{
|
||||
for(QBitMetaData qBit : qInstance.getQBits().values())
|
||||
{
|
||||
if(qBit.getConfig() instanceof SqlWorkbenchQBitConfig config && config.getRuntime() != null)
|
||||
{
|
||||
return (config.getRuntime());
|
||||
}
|
||||
}
|
||||
}
|
||||
throw (new QNotFoundException("The SQL Workbench is not installed in this application."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached schema for a backend, if still fresh.
|
||||
*******************************************************************************/
|
||||
public CachedSchema getCachedSchema(String backendName)
|
||||
{
|
||||
CachedSchema cached = schemaCache.get(backendName);
|
||||
if(cached != null && cached.cachedAt().plusSeconds(config.getSchemaCacheTtlSeconds()).isAfter(clock.instant()))
|
||||
{
|
||||
return (cached);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void putCachedSchema(String backendName, Map<String, Object> schema)
|
||||
{
|
||||
schemaCache.put(backendName, new CachedSchema(schema, clock.instant()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release in-memory state (cache, tokens). Running statements finish on
|
||||
** their own.
|
||||
*******************************************************************************/
|
||||
public void close()
|
||||
{
|
||||
resultCache.clear();
|
||||
schemaCache.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for config
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchQBitConfig getConfig()
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clock
|
||||
*******************************************************************************/
|
||||
public Clock getClock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resultCache
|
||||
*******************************************************************************/
|
||||
public ResultCache getResultCache()
|
||||
{
|
||||
return (resultCache);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runningStatements
|
||||
*******************************************************************************/
|
||||
public RunningStatements getRunningStatements()
|
||||
{
|
||||
return (runningStatements);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writePreviewTokens
|
||||
*******************************************************************************/
|
||||
public WritePreviewTokens getWritePreviewTokens()
|
||||
{
|
||||
return (writePreviewTokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Derived backend meta-data (dedicated credentials), by derived name.
|
||||
*******************************************************************************/
|
||||
public Map<String, RDBMSBackendMetaData> getDerivedBackends()
|
||||
{
|
||||
return (derivedBackends);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench's record-security keys.
|
||||
**
|
||||
** Two security key types guard the qbit's tables:
|
||||
** - sql_workbench_user: the session's user id. Owner locks (a notebook's
|
||||
** ownerUserId, an execution's userId) match it. Its all-access key,
|
||||
** sql_workbench_all_access, is set for workbench admins.
|
||||
** - sql_workbench_audience: the session's audience keys: "user:<id>",
|
||||
** "group:<name>" per group, and "all". Share locks (a share row's
|
||||
** audienceKey) match it, for reads only.
|
||||
**
|
||||
** Hosts call applyToSession from their authentication customizer, after
|
||||
** the session's user and permissions are set, passing the user's groups
|
||||
** (if the app has any). Without the keys the locks deny: the tables then
|
||||
** show a user nothing, never someone else's rows.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchSecurity
|
||||
{
|
||||
public static final String AUDIENCE_ALL = ShareAudienceType.ALL.audienceKey(null);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchSecurity()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The two security key types, for the producer.
|
||||
*******************************************************************************/
|
||||
public static List<QSecurityKeyType> buildSecurityKeyTypes(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
return (List.of(
|
||||
new QSecurityKeyType().withName(config.getUserSecurityKeyTypeName()).withAllAccessKeyName(config.getAllAccessSecurityKeyName()),
|
||||
new QSecurityKeyType().withName(config.getAudienceSecurityKeyTypeName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Put the workbench security keys into a session, using the installed
|
||||
** workbench's configuration. Idempotent: earlier workbench keys are
|
||||
** replaced.
|
||||
*******************************************************************************/
|
||||
public static void applyToSession(QInstance qInstance, QSession session, Collection<String> groups) throws QNotFoundException
|
||||
{
|
||||
applyToSession(SqlWorkbenchRuntime.get(qInstance).getConfig(), session, groups);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Put the workbench security keys into a session: the user id, the
|
||||
** audience keys (user, each group, all) and, for holders of the admin
|
||||
** permission, the all-access key. A session without a user id or
|
||||
** without the workbench access permission gets none, so the locks deny.
|
||||
** Idempotent.
|
||||
*******************************************************************************/
|
||||
public static void applyToSession(SqlWorkbenchQBitConfig config, QSession session, Collection<String> groups)
|
||||
{
|
||||
if(session == null || session instanceof QSystemUserSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, List<Serializable>> keys = session.getSecurityKeyValues();
|
||||
if(keys != null)
|
||||
{
|
||||
keys.remove(config.getUserSecurityKeyTypeName());
|
||||
keys.remove(config.getAudienceSecurityKeyTypeName());
|
||||
keys.remove(config.getAllAccessSecurityKeyName());
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// no workbench access, no keys: even a code path that skips table //
|
||||
// permissions then finds no workbench rows (not even the library) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
String userId = userId(session);
|
||||
if(!StringUtils.hasContent(userId) || !session.hasPermission(config.getAccessPermissionName()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
session.withSecurityKeyValue(config.getUserSecurityKeyTypeName(), userId);
|
||||
for(String audienceKey : audienceKeys(userId, groups))
|
||||
{
|
||||
session.withSecurityKeyValue(config.getAudienceSecurityKeyTypeName(), audienceKey);
|
||||
}
|
||||
if(session.hasPermission(config.getAdminPermissionName()))
|
||||
{
|
||||
session.withSecurityKeyValue(config.getAllAccessSecurityKeyName(), true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the keys only when the session has none yet (keeping the groups a
|
||||
** host already applied). The workbench endpoints call this, so a host
|
||||
** that never calls applyToSession still gets owner and library access
|
||||
** there; group shares need the host to pass the groups.
|
||||
*******************************************************************************/
|
||||
public static void ensureSessionKeys(SqlWorkbenchQBitConfig config, QSession session)
|
||||
{
|
||||
if(session != null && session.getSecurityKeyValues(config.getUserSecurityKeyTypeName()).isEmpty())
|
||||
{
|
||||
applyToSession(config, session, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The audience keys of a user in some groups: "user:<id>", one
|
||||
** "group:<name>" per group, and "all".
|
||||
*******************************************************************************/
|
||||
public static List<String> audienceKeys(String userId, Collection<String> groups)
|
||||
{
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
if(StringUtils.hasContent(userId))
|
||||
{
|
||||
keys.add(ShareAudienceType.USER.audienceKey(userId));
|
||||
}
|
||||
if(groups != null)
|
||||
{
|
||||
for(String group : groups)
|
||||
{
|
||||
if(StringUtils.hasContent(group))
|
||||
{
|
||||
keys.add(ShareAudienceType.GROUP.audienceKey(group));
|
||||
}
|
||||
}
|
||||
}
|
||||
keys.add(AUDIENCE_ALL);
|
||||
return (new ArrayList<>(keys));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The current session's audience keys, as applied.
|
||||
*******************************************************************************/
|
||||
public static List<String> sessionAudienceKeys(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
List<String> keys = new ArrayList<>();
|
||||
QSession session = QContext.getQSession();
|
||||
if(session != null)
|
||||
{
|
||||
for(Serializable value : session.getSecurityKeyValues(config.getAudienceSecurityKeyTypeName()))
|
||||
{
|
||||
keys.add(String.valueOf(value));
|
||||
}
|
||||
}
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for the system user and for sessions holding the all-access key.
|
||||
*******************************************************************************/
|
||||
public static boolean hasAllAccess(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
return (session instanceof QSystemUserSession || (session != null && session.hasSecurityKeyValue(config.getAllAccessSecurityKeyName(), true, QFieldType.BOOLEAN)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the current session is the system user.
|
||||
*******************************************************************************/
|
||||
public static boolean isSystemUser()
|
||||
{
|
||||
return (QContext.getQSession() instanceof QSystemUserSession);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The session's user id (the user's id reference, else the session's).
|
||||
*******************************************************************************/
|
||||
public static String userId(QSession session)
|
||||
{
|
||||
if(session == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(session.getUser() != null && StringUtils.hasContent(session.getUser().getIdReference()))
|
||||
{
|
||||
return (session.getUser().getIdReference());
|
||||
}
|
||||
return (session.getIdReference());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The session's user name (full name, else the id).
|
||||
*******************************************************************************/
|
||||
public static String userName(QSession session)
|
||||
{
|
||||
if(session != null && session.getUser() != null && StringUtils.hasContent(session.getUser().getFullName()))
|
||||
{
|
||||
return (session.getUser().getFullName());
|
||||
}
|
||||
return (userId(session));
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
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.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs a block as the QQQ system user, in the current instance.
|
||||
**
|
||||
** The qbit's tables are guarded by the admin permission, which ordinary
|
||||
** workbench users do not hold. The workbench's own services (notebooks,
|
||||
** history, the audit) first check the caller - owner, visibility, share
|
||||
** permission - in code, and only then read or write those tables through
|
||||
** this. Never call it before those checks.
|
||||
*******************************************************************************/
|
||||
public final class SystemUser
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SystemUser()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a block as the system user.
|
||||
*******************************************************************************/
|
||||
public static void run(Block block) throws QException
|
||||
{
|
||||
call(() ->
|
||||
{
|
||||
block.run();
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a block as the system user and return its value.
|
||||
*******************************************************************************/
|
||||
public static <T> T call(Supplier<T> supplier) throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
Object[] result = new Object[1];
|
||||
QContext.<QException>withTemporaryContext(new CapturedContext(qInstance, new QSystemUserSession()), () -> result[0] = supplier.get());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
T value = (T) result[0];
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A block that may throw QException.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Block
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A value-returning block that may throw QException.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Supplier<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
T get() throws QException;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.CollectedResult;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
|
||||
import org.dhatim.fastexcel.Workbook;
|
||||
import org.dhatim.fastexcel.Worksheet;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes a cached result as CSV (RFC 4180, UTF-8 with BOM for Excel), JSON
|
||||
** (array of objects keyed by unique column labels) or XLSX (fastexcel,
|
||||
** which qqq-backend-core already ships).
|
||||
*******************************************************************************/
|
||||
public class ResultExporter
|
||||
{
|
||||
private static final int XLSX_MAX_CELL_CHARS = 32_767;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supported formats.
|
||||
*******************************************************************************/
|
||||
public enum Format
|
||||
{
|
||||
CSV("text/csv; charset=utf-8", "csv"),
|
||||
JSON("application/json", "json"),
|
||||
XLSX("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx");
|
||||
|
||||
private final String contentType;
|
||||
private final String extension;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Format(String contentType, String extension)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a format name (case-insensitive).
|
||||
*******************************************************************************/
|
||||
public static Format of(String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Format.valueOf(String.valueOf(name).toUpperCase(Locale.ROOT)));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("format must be csv, json or xlsx."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getContentType()
|
||||
{
|
||||
return (contentType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getExtension()
|
||||
{
|
||||
return (extension);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write the result in a format.
|
||||
*******************************************************************************/
|
||||
public static void write(CollectedResult result, Format format, OutputStream out) throws IOException
|
||||
{
|
||||
switch(format)
|
||||
{
|
||||
case CSV -> writeCsv(result, out);
|
||||
case JSON -> writeJson(result, out);
|
||||
case XLSX -> writeXlsx(result, out);
|
||||
default -> throw (new IllegalStateException("Unexpected format: " + format));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void writeCsv(CollectedResult result, OutputStream out) throws IOException
|
||||
{
|
||||
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
|
||||
writer.write('');
|
||||
List<String> header = result.columns().stream().map(ColumnInfo::label).toList();
|
||||
writer.write(csvLine(new ArrayList<>(header)));
|
||||
for(List<Object> row : result.rows())
|
||||
{
|
||||
writer.write(csvLine(row));
|
||||
}
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CSV formula injection: a spreadsheet runs a text cell that starts with
|
||||
** =, +, -, @, tab or CR as a formula. Prefix those with a quote so they
|
||||
** open as text. (Numbers are written as numbers and are left alone.)
|
||||
*******************************************************************************/
|
||||
static String neutralizeFormula(String text)
|
||||
{
|
||||
if(!text.isEmpty() && "=+-@\t\r".indexOf(text.charAt(0)) >= 0)
|
||||
{
|
||||
return ("'" + text);
|
||||
}
|
||||
return (text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String csvLine(List<?> values)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(int i = 0; i < values.size(); i++)
|
||||
{
|
||||
if(i > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
Object value = values.get(i);
|
||||
if(value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String text = value instanceof String s ? neutralizeFormula(s) : value instanceof Number || value instanceof Boolean ? value.toString() : JsonUtils.toJson(value);
|
||||
if(text.contains(",") || text.contains("\"") || text.contains("\n") || text.contains("\r"))
|
||||
{
|
||||
text = "\"" + text.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
sb.append(text);
|
||||
}
|
||||
return (sb.append("\r\n").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void writeJson(CollectedResult result, OutputStream out) throws IOException
|
||||
{
|
||||
List<String> keys = uniqueLabels(result.columns());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(List<Object> row : result.rows())
|
||||
{
|
||||
Map<String, Object> object = new LinkedHashMap<>();
|
||||
for(int i = 0; i < keys.size(); i++)
|
||||
{
|
||||
object.put(keys.get(i), row.get(i));
|
||||
}
|
||||
rows.add(object);
|
||||
}
|
||||
out.write(JsonUtils.toJson(rows).getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void writeXlsx(CollectedResult result, OutputStream out) throws IOException
|
||||
{
|
||||
Workbook workbook = new Workbook(out, "QQQ SQL Workbench", "1.0");
|
||||
Worksheet worksheet = workbook.newWorksheet("Result");
|
||||
List<ColumnInfo> columns = result.columns();
|
||||
for(int c = 0; c < columns.size(); c++)
|
||||
{
|
||||
worksheet.value(0, c, columns.get(c).label());
|
||||
worksheet.style(0, c).bold().set();
|
||||
}
|
||||
|
||||
for(int r = 0; r < result.rows().size(); r++)
|
||||
{
|
||||
List<Object> row = result.rows().get(r);
|
||||
for(int c = 0; c < row.size(); c++)
|
||||
{
|
||||
Object value = row.get(c);
|
||||
if(value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(value instanceof Number n && "number".equals(columns.get(c).kind()))
|
||||
{
|
||||
worksheet.value(r + 1, c, n instanceof BigDecimal bd ? bd : new BigDecimal(n.toString()));
|
||||
}
|
||||
else if(value instanceof Boolean b)
|
||||
{
|
||||
worksheet.value(r + 1, c, b);
|
||||
}
|
||||
else
|
||||
{
|
||||
String text = value instanceof String s ? s : JsonUtils.toJson(value);
|
||||
worksheet.value(r + 1, c, text.length() > XLSX_MAX_CELL_CHARS ? text.substring(0, XLSX_MAX_CELL_CHARS) : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
workbook.finish();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Column labels made unique (a, a_2, a_3...), for JSON keys.
|
||||
*******************************************************************************/
|
||||
static List<String> uniqueLabels(List<ColumnInfo> columns)
|
||||
{
|
||||
Set<String> seen = new HashSet<>();
|
||||
List<String> labels = new ArrayList<>();
|
||||
for(ColumnInfo column : columns)
|
||||
{
|
||||
String label = column.label() == null ? column.name() : column.label();
|
||||
String key = label;
|
||||
for(int n = 2; !seen.add(key); n++)
|
||||
{
|
||||
key = label + "_" + n;
|
||||
}
|
||||
labels.add(key);
|
||||
}
|
||||
return (labels);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionRequest;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.SqlExecutionEngine;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qbits.sqlworkbench.schema.SchemaIntrospector;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SQL Workbench API, independent of HTTP: maps in, maps out, QQQ
|
||||
** exceptions for errors (400 / 401 / 403 / 404). Works on the QContext's
|
||||
** instance and session; every method first requires the access permission.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchApi
|
||||
{
|
||||
private final QInstance qInstance;
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
private final SqlExecutionEngine engine;
|
||||
private final NotebookService notebooks;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For the QContext's instance.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchApi() throws QNotFoundException
|
||||
{
|
||||
this(QContext.getQInstance(), SqlWorkbenchRuntime.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchApi(QInstance qInstance, SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.runtime = runtime;
|
||||
this.config = runtime.getConfig();
|
||||
this.engine = new SqlExecutionEngine(qInstance, runtime);
|
||||
this.notebooks = new NotebookService(runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The caller, after checking the access permission.
|
||||
*******************************************************************************/
|
||||
public Caller caller(String clientIp) throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null)
|
||||
{
|
||||
throw (new QAuthenticationException("Not authenticated."));
|
||||
}
|
||||
if(!session.hasPermission(config.getAccessPermissionName()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
|
||||
if(!StringUtils.hasContent(SqlWorkbenchSecurity.userId(session)))
|
||||
{
|
||||
throw (new QAuthenticationException("The session has no user."));
|
||||
}
|
||||
SqlWorkbenchSecurity.ensureSessionKeys(config, session);
|
||||
return (Caller.fromSession(config, session, clientIp));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /capabilities
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> capabilities(Caller caller)
|
||||
{
|
||||
Map<String, Object> limits = new LinkedHashMap<>();
|
||||
limits.put("maxRows", config.getMaxRows());
|
||||
limits.put("pageSize", config.getDefaultPageSize());
|
||||
limits.put("maxPageSize", config.getMaxPageSize());
|
||||
limits.put("maxResultBytes", config.getMaxResultBytes());
|
||||
limits.put("statementTimeoutSeconds", config.getStatementTimeoutSeconds());
|
||||
limits.put("writeStatementTimeoutSeconds", config.getWriteStatementTimeoutSeconds());
|
||||
limits.put("maxAffectedRowsWithoutAcknowledgement", config.getMaxAffectedRowsWithoutAcknowledgement());
|
||||
limits.put("maxAffectedRows", config.getMaxAffectedRows());
|
||||
limits.put("maxSqlLength", config.getMaxSqlLength());
|
||||
limits.put("maxStatementsPerRun", config.getMaxStatementsPerRun());
|
||||
|
||||
List<Map<String, Object>> backends = new ArrayList<>();
|
||||
for(WorkbenchBackends.Target target : engine.getBackends().listTargets())
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("name", target.name());
|
||||
view.put("label", target.name());
|
||||
view.put("vendor", target.backend().getVendor());
|
||||
view.put("dialect", target.dialect().getUiName());
|
||||
view.put("readOnlyCredentials", engine.getBackends().hasReadOnlyCredentials(target));
|
||||
view.put("readOnlyEnforced", target.dialect() != SqlDialect.H2 && target.dialect() != SqlDialect.GENERIC || engine.getBackends().hasReadOnlyCredentials(target));
|
||||
view.put("transactionalDdl", target.dialect().isTransactionalDdl());
|
||||
view.put("writeAllowed", caller.canWrite());
|
||||
backends.add(view);
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("version", SqlWorkbenchQBitProducer.getVersion());
|
||||
body.put("canWrite", caller.canWrite());
|
||||
body.put("canShare", caller.canShare());
|
||||
body.put("writeModeEnabled", config.getWriteModeEnabled());
|
||||
body.put("limits", limits);
|
||||
body.put("backends", backends);
|
||||
return (body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /schema/{backendName}
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> schema(Caller caller, String backendName, boolean refresh) throws QException
|
||||
{
|
||||
return (new SchemaIntrospector(qInstance, runtime, engine.getBackends()).describe(backendName, refresh));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /execute
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> execute(Caller caller, Map<String, Object> body) throws QException
|
||||
{
|
||||
ExecutionRequest request = new ExecutionRequest(
|
||||
requireString(body, "backendName"),
|
||||
requireString(body, "sql"),
|
||||
paramsOf(body),
|
||||
"WRITE".equalsIgnoreCase(String.valueOf(body.get("mode"))),
|
||||
intOrNull(body.get("notebookId")),
|
||||
body.get("cellKey") == null ? null : body.get("cellKey").toString(),
|
||||
intOrNull(body.get("pageSize")),
|
||||
body.get("confirmToken") == null ? null : body.get("confirmToken").toString(),
|
||||
Boolean.TRUE.equals(body.get("acknowledgeRowGuard")),
|
||||
body.get("executionUuid") == null ? null : normalizeUuid(body.get("executionUuid").toString()));
|
||||
return (engine.execute(request, caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /explain
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> explain(Caller caller, Map<String, Object> body) throws QException
|
||||
{
|
||||
return (engine.explain(requireString(body, "backendName"), requireString(body, "sql"), paramsOf(body), Boolean.TRUE.equals(body.get("analyze")), caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /executions/{uuid}/rows
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> rows(Caller caller, String executionUuid, Integer offset, Integer limit, Integer sortColumn, String direction) throws QException
|
||||
{
|
||||
String uuid = normalizeUuid(executionUuid);
|
||||
ResultCache.Entry entry = cached(caller, uuid);
|
||||
int from = offset == null ? 0 : Math.max(0, offset);
|
||||
int size = limit == null || limit <= 0 ? config.getDefaultPageSize() : Math.min(limit, config.getMaxPageSize());
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("executionUuid", uuid);
|
||||
body.put("columns", entry.result().columns().stream().map(ColumnInfo::toMap).toList());
|
||||
body.put("rows", ResultCache.page(entry.result(), from, size, sortColumn, "desc".equalsIgnoreCase(direction)));
|
||||
body.put("offset", from);
|
||||
body.put("rowCount", entry.result().rows().size());
|
||||
body.put("truncated", entry.result().truncated());
|
||||
body.put("truncatedReason", entry.result().truncatedReason());
|
||||
return (body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /executions/{uuid}/export - the cached entry to write.
|
||||
*******************************************************************************/
|
||||
public ResultCache.Entry exportable(Caller caller, String executionUuid) throws QException
|
||||
{
|
||||
return (cached(caller, normalizeUuid(executionUuid)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /executions/{uuid}/cancel
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> cancel(Caller caller, String executionUuid)
|
||||
{
|
||||
return (Map.of("cancelled", engine.cancel(normalizeUuid(executionUuid), caller)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /history - the caller's own executions, newest first.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> history(Caller caller, Integer limit, String backendName) throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, caller.userId()))
|
||||
.withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withLimit(limit == null || limit <= 0 ? 50 : Math.min(limit, 200));
|
||||
if(StringUtils.hasContent(backendName))
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("backendName", QCriteriaOperator.EQUALS, backendName));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> executions = new ArrayList<>();
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// read as the caller: the audit's record lock limits them to their //
|
||||
// own rows (admins see all, so the filter keeps history personal) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
for(QRecord record : QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), filter))
|
||||
{
|
||||
SqlWorkbenchExecution execution = new SqlWorkbenchExecution(record);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("executionUuid", execution.getExecutionUuid());
|
||||
view.put("backendName", execution.getBackendName());
|
||||
view.put("mode", execution.getMode());
|
||||
view.put("status", execution.getStatus());
|
||||
view.put("sqlText", execution.getSqlText());
|
||||
view.put("notebookId", execution.getNotebookId());
|
||||
view.put("cellKey", execution.getCellKey());
|
||||
view.put("startTime", execution.getStartTime() == null ? null : execution.getStartTime().toString());
|
||||
view.put("durationMs", execution.getDurationMs());
|
||||
view.put("rowsReturned", execution.getRowsReturned());
|
||||
view.put("rowsAffected", execution.getRowsAffected());
|
||||
view.put("errorMessage", execution.getErrorMessage());
|
||||
executions.add(view);
|
||||
}
|
||||
return (Map.of("executions", executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook endpoints.
|
||||
*******************************************************************************/
|
||||
public NotebookService notebooks()
|
||||
{
|
||||
return (notebooks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /notebooks/{id}/snapshots - the snapshot keeps the audited SQL.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> createSnapshot(Caller caller, Integer notebookId, Map<String, Object> body) throws QException
|
||||
{
|
||||
String executionUuid = normalizeUuid(requireString(body, "executionUuid"));
|
||||
cached(caller, executionUuid);
|
||||
List<QRecord> audit = QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME),
|
||||
new QQueryFilter(new QFilterCriteria("executionUuid", QCriteriaOperator.EQUALS, executionUuid))
|
||||
.withCriteria(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, caller.userId()))
|
||||
.withLimit(1));
|
||||
String sqlText = audit.isEmpty() ? null : audit.get(0).getValueString("sqlText");
|
||||
|
||||
Map<String, Object> normalized = new LinkedHashMap<>(body);
|
||||
normalized.put("executionUuid", executionUuid);
|
||||
return (notebooks.createSnapshot(notebookId, normalized, caller, sqlText));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private ResultCache.Entry cached(Caller caller, String executionUuid) throws QNotFoundException
|
||||
{
|
||||
return (runtime.getResultCache().get(executionUuid, caller.userId())
|
||||
.orElseThrow(() -> new QNotFoundException("That result is no longer available; run the SQL again.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execution ids are stored and cached lower-case; accept any case (and
|
||||
** surrounding blanks) from clients.
|
||||
*******************************************************************************/
|
||||
static String normalizeUuid(String executionUuid)
|
||||
{
|
||||
return (executionUuid == null ? null : executionUuid.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> paramsOf(Map<String, Object> body) throws QBadRequestException
|
||||
{
|
||||
Object params = body.get("params");
|
||||
if(params == null)
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
if(!(params instanceof Map<?, ?>))
|
||||
{
|
||||
throw (new QBadRequestException("params must be an object of name: value."));
|
||||
}
|
||||
return (new LinkedHashMap<>((Map<String, Object>) params));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String requireString(Map<String, Object> body, String key) throws QBadRequestException
|
||||
{
|
||||
Object value = body == null ? null : body.get(key);
|
||||
if(value == null || !StringUtils.hasContent(value.toString()))
|
||||
{
|
||||
throw (new QBadRequestException(key + " is required."));
|
||||
}
|
||||
return (value.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Integer intOrNull(Object value) throws QBadRequestException
|
||||
{
|
||||
if(value == null || "".equals(value))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (n.intValue());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(value.toString().trim()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException("Expected an integer, got: " + value));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.DuplicateExecutionException;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.ContentType;
|
||||
import io.javalin.http.Context;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Javalin routes for the SQL Workbench under /qqq/v1/sql-workbench (see
|
||||
** docs/DESIGN.md section 5 for the contract).
|
||||
**
|
||||
** Each request gets a session from the instance's authentication module
|
||||
** (ExecutorSessionUtils), then SqlWorkbenchApi checks permissions and does
|
||||
** the work. Errors map through QJavalinUtils.handleException: 400 bad
|
||||
** request, 401 unauthenticated, 403 not permitted, 404 unknown, 409 a
|
||||
** re-used executionUuid. POST / PUT
|
||||
** bodies must be JSON (Content-Type application/json): a cross-site form
|
||||
** cannot send that without a CORS preflight. Request and response bodies
|
||||
** are never logged.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
public static final String BASE_PATH = "/qqq/v1/sql-workbench";
|
||||
|
||||
private QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setQInstance(QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get(BASE_PATH + "/capabilities", ctx -> respond(ctx, false, (api, caller, body) -> api.capabilities(caller)));
|
||||
config.routes.get(BASE_PATH + "/schema/{backendName}", ctx -> respond(ctx, false, (api, caller, body) -> api.schema(caller, ctx.pathParam("backendName"), "true".equalsIgnoreCase(ctx.queryParam("refresh")))));
|
||||
config.routes.post(BASE_PATH + "/execute", ctx -> respond(ctx, true, (api, caller, body) -> api.execute(caller, body)));
|
||||
config.routes.post(BASE_PATH + "/explain", ctx -> respond(ctx, true, (api, caller, body) -> api.explain(caller, body)));
|
||||
config.routes.get(BASE_PATH + "/executions/{uuid}/rows", ctx -> respond(ctx, false, (api, caller, body) -> api.rows(caller, ctx.pathParam("uuid"),
|
||||
intParam(ctx, "offset"), intParam(ctx, "limit"), intParam(ctx, "sort"), ctx.queryParam("dir"))));
|
||||
config.routes.get(BASE_PATH + "/executions/{uuid}/export", this::export);
|
||||
config.routes.post(BASE_PATH + "/executions/{uuid}/cancel", ctx -> respond(ctx, true, (api, caller, body) -> api.cancel(caller, ctx.pathParam("uuid"))));
|
||||
config.routes.get(BASE_PATH + "/history", ctx -> respond(ctx, false, (api, caller, body) -> api.history(caller, intParam(ctx, "limit"), ctx.queryParam("backendName"))));
|
||||
|
||||
config.routes.get(BASE_PATH + "/notebooks", ctx -> respond(ctx, false, (api, caller, body) -> Map.of("notebooks", api.notebooks().list(ctx.queryParam("scope"), caller))));
|
||||
config.routes.post(BASE_PATH + "/notebooks", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().create(body, caller)));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}", ctx -> respond(ctx, false, (api, caller, body) -> api.notebooks().get(id(ctx), caller)));
|
||||
config.routes.put(BASE_PATH + "/notebooks/{id}", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().update(id(ctx), body, caller)));
|
||||
config.routes.delete(BASE_PATH + "/notebooks/{id}", ctx -> respond(ctx, false, (api, caller, body) ->
|
||||
{
|
||||
api.notebooks().delete(id(ctx), caller);
|
||||
return (Map.of("deleted", true));
|
||||
}));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/publish", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().publish(id(ctx), Boolean.TRUE.equals(body.get("shared")), caller)));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/duplicate", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().duplicate(id(ctx), caller)));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}/versions", ctx -> respond(ctx, false, (api, caller, body) -> Map.of("versions", api.notebooks().versions(id(ctx), caller))));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}/versions/{n}", ctx -> respond(ctx, false, (api, caller, body) -> api.notebooks().version(id(ctx), pathInt(ctx, "n"), caller)));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/versions/{n}/restore", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().restore(id(ctx), pathInt(ctx, "n"), caller)));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}/snapshots", ctx -> respond(ctx, false, (api, caller, body) -> Map.of("snapshots", api.notebooks().snapshots(id(ctx), caller))));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/snapshots", ctx -> respond(ctx, true, (api, caller, body) -> api.createSnapshot(caller, id(ctx), body)));
|
||||
config.routes.delete(BASE_PATH + "/notebooks/{id}/snapshots/{snapshotId}", ctx -> respond(ctx, false, (api, caller, body) ->
|
||||
{
|
||||
api.notebooks().deleteSnapshot(id(ctx), pathInt(ctx, "snapshotId"), caller);
|
||||
return (Map.of("deleted", true));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session, permission check, body, handler, JSON - or the error's status.
|
||||
*******************************************************************************/
|
||||
private void respond(Context context, boolean readsBody, Handler handler)
|
||||
{
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, qInstance);
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Caller caller = api.caller(context.ip());
|
||||
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
|
||||
Object result = handler.handle(api, caller, body);
|
||||
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
QJavalinUtils.handleException(e instanceof DuplicateExecutionException ? HttpStatus.Code.CONFLICT : null, context, e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /executions/{uuid}/export?format=csv|json|xlsx
|
||||
*******************************************************************************/
|
||||
private void export(Context context)
|
||||
{
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, qInstance);
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Caller caller = api.caller(context.ip());
|
||||
ResultExporter.Format format = ResultExporter.Format.of(StringUtils.hasContent(context.queryParam("format")) ? context.queryParam("format") : "csv");
|
||||
String uuid = context.pathParam("uuid");
|
||||
ResultCache.Entry entry = api.exportable(caller, uuid);
|
||||
|
||||
String shortId = uuid.length() > 8 ? uuid.substring(0, 8) : uuid;
|
||||
context.contentType(format.getContentType());
|
||||
context.header("Content-Disposition", "attachment; filename=\"sql-result-" + shortId.replaceAll("[^A-Za-z0-9-]", "") + "." + format.getExtension() + "\"");
|
||||
context.header("Cache-Control", "no-store");
|
||||
ResultExporter.write(entry.result(), format, context.outputStream());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
QJavalinUtils.handleException(null, context, e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A JSON object body. Decimals stay exact (BigDecimal).
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> readJsonBody(Context context) throws QBadRequestException
|
||||
{
|
||||
String body = context.body();
|
||||
if(!StringUtils.hasContent(body))
|
||||
{
|
||||
return (new LinkedHashMap<>());
|
||||
}
|
||||
|
||||
String contentType = context.contentType();
|
||||
if(contentType == null || !contentType.toLowerCase().startsWith("application/json"))
|
||||
{
|
||||
throw (new QBadRequestException("Request bodies must be JSON (Content-Type: application/json)."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Map<String, Object> map = JsonUtils.toObject(body, new TypeReference<Map<String, Object>>() {}, mapper -> mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS));
|
||||
return (map == null ? new LinkedHashMap<>() : map);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QBadRequestException("The request body is not a JSON object."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer id(Context context) throws QBadRequestException
|
||||
{
|
||||
return (pathInt(context, "id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer pathInt(Context context, String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(context.pathParam(name)));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be an integer."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer intParam(Context context, String name) throws QBadRequestException
|
||||
{
|
||||
String value = context.queryParam(name);
|
||||
if(!StringUtils.hasContent(value))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(value.trim()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be an integer."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One endpoint's work.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Handler
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Object handle(SqlWorkbenchApi api, Caller caller, Map<String, Object> body) throws QException;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers SqlWorkbenchRouteProvider (the /qqq/v1/sql-workbench routes)
|
||||
** with the Javalin server through the instance's QJavalinMetaData - as
|
||||
** qqq-esb's EsbJavalinMetaDataProducer does. Only touched when
|
||||
** qqq-middleware-javalin is on the classpath (the producer checks first).
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRoutes
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the route provider reference, once.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
QJavalinMetaData javalinMetaData = QJavalinMetaData.ofOrWithNew(qInstance);
|
||||
boolean registered = CollectionUtils.nonNullList(javalinMetaData.getAdditionalRouteProviderReferences()).stream()
|
||||
.anyMatch(reference -> reference != null && SqlWorkbenchRouteProvider.class.getName().equals(reference.getName()));
|
||||
if(!registered)
|
||||
{
|
||||
javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(SqlWorkbenchRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.customizers;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook versions and the execution audit are append-only: only the qbit
|
||||
** itself (the system user) inserts them, and only its retention pruning
|
||||
** deletes them. The tables also lack the insert / update / delete
|
||||
** capabilities, which hides those operations in the UI and API; this
|
||||
** customizer enforces it in the core actions, whoever calls them (admins
|
||||
** included).
|
||||
*******************************************************************************/
|
||||
public class AppendOnlyTableCustomizer extends WorkbenchTableCustomizer
|
||||
{
|
||||
private static final String MESSAGE = "This table is append-only: only the SQL Workbench writes it.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
return (denyUnlessSystem(records));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
return (denyUnlessSystem(records));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
return (denyUnlessSystem(records));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> denyUnlessSystem(List<QRecord> records)
|
||||
{
|
||||
if(!isSystemUser())
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
denied(record, MESSAGE);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.customizers;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
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.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cell invariants, the same for the notebook UI and the table UI / API:
|
||||
** type SQL or MARKDOWN, content within the SQL length limit, a stable
|
||||
** cellKey, at most NotebookService.MAX_CELLS per notebook, new cells
|
||||
** appended at the end, cells never move between notebooks, and every
|
||||
** change appends a notebook version. Ownership is the notebook's (the
|
||||
** record lock checks it through the notebook).
|
||||
*******************************************************************************/
|
||||
public class CellTableCustomizer extends WorkbenchTableCustomizer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = config();
|
||||
Instant now = clock().instant();
|
||||
boolean wholeNotebook = insertInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) || insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL);
|
||||
|
||||
Map<Integer, int[]> nextSortOrderAndCount = new HashMap<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
Integer notebookId = record.getValueInteger("notebookId");
|
||||
if(notebookId == null)
|
||||
{
|
||||
badInput(record, "A cell needs a notebook.");
|
||||
continue;
|
||||
}
|
||||
|
||||
validate(config, record, true);
|
||||
if(!StringUtils.hasContent(record.getValueString("cellKey")))
|
||||
{
|
||||
record.setValue("cellKey", UUID.randomUUID().toString());
|
||||
}
|
||||
record.setValue("createDate", now);
|
||||
record.setValue("modifyDate", now);
|
||||
|
||||
if(!wholeNotebook)
|
||||
{
|
||||
int[] state = nextSortOrderAndCount.get(notebookId);
|
||||
if(state == null)
|
||||
{
|
||||
state = existing(config, notebookId);
|
||||
nextSortOrderAndCount.put(notebookId, state);
|
||||
}
|
||||
if(state[1] >= NotebookService.MAX_CELLS)
|
||||
{
|
||||
badInput(record, "A notebook can have at most " + NotebookService.MAX_CELLS + " cells.");
|
||||
continue;
|
||||
}
|
||||
state[1]++;
|
||||
if(record.getValueInteger("sortOrder") == null)
|
||||
{
|
||||
record.setValue("sortOrder", state[0]++);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
if(!insertInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
appendVersions(notebookIds(records), "Cell added", insertInput.getTransaction());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
SqlWorkbenchQBitConfig config = config();
|
||||
Instant now = clock().instant();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!ok(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord old = oldRecord(oldRecordList, record);
|
||||
if(changes(record, old, "notebookId"))
|
||||
{
|
||||
badInput(record, "A cell cannot move to another notebook.");
|
||||
continue;
|
||||
}
|
||||
if(changes(record, old, "cellKey"))
|
||||
{
|
||||
badInput(record, "A cell's key cannot change.");
|
||||
continue;
|
||||
}
|
||||
record.removeValue("createDate");
|
||||
validate(config, record, false);
|
||||
record.setValue("modifyDate", now);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(!updateInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = oldRecord(oldRecordList, record);
|
||||
if(ok(record) && old != null && old.getValueInteger("notebookId") != null)
|
||||
{
|
||||
ids.add(old.getValueInteger("notebookId"));
|
||||
}
|
||||
}
|
||||
appendVersions(ids, "Cell edited", updateInput.getTransaction());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
if(!deleteInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !deleteInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
appendVersions(notebookIds(records), "Cell removed", deleteInput.getTransaction());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Type, content and key checks.
|
||||
*******************************************************************************/
|
||||
private static void validate(SqlWorkbenchQBitConfig config, QRecord record, boolean inserting)
|
||||
{
|
||||
if(inserting || record.getValues().containsKey("cellType"))
|
||||
{
|
||||
String type = record.getValueString("cellType");
|
||||
type = StringUtils.hasContent(type) ? type.trim().toUpperCase(Locale.ROOT) : NotebookService.CELL_SQL;
|
||||
if(!NotebookService.CELL_SQL.equals(type) && !NotebookService.CELL_MARKDOWN.equals(type))
|
||||
{
|
||||
badInput(record, "cellType must be SQL or MARKDOWN.");
|
||||
return;
|
||||
}
|
||||
record.setValue("cellType", type);
|
||||
}
|
||||
|
||||
if(inserting && record.getValue("content") == null)
|
||||
{
|
||||
record.setValue("content", "");
|
||||
}
|
||||
String content = record.getValueString("content");
|
||||
if(content != null && content.length() > config.getMaxSqlLength())
|
||||
{
|
||||
badInput(record, "A cell is longer than the limit of " + config.getMaxSqlLength() + " characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
String key = record.getValueString("cellKey");
|
||||
if(key != null && key.length() > 64)
|
||||
{
|
||||
badInput(record, "A cell key is at most 64 characters.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {next sort order, current cell count} of a notebook, as the session sees
|
||||
** it (a notebook the session cannot read counts as empty; its insert then
|
||||
** fails the record lock).
|
||||
*******************************************************************************/
|
||||
private static int[] existing(SqlWorkbenchQBitConfig config, Integer notebookId) throws QException
|
||||
{
|
||||
List<QRecord> cells = new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchCell.TABLE_NAME))
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)))).getRecords();
|
||||
int max = -1;
|
||||
for(QRecord cell : cells)
|
||||
{
|
||||
Integer sortOrder = cell.getValueInteger("sortOrder");
|
||||
if(sortOrder != null && sortOrder > max)
|
||||
{
|
||||
max = sortOrder;
|
||||
}
|
||||
}
|
||||
return (new int[] { max + 1, cells.size() });
|
||||
}
|
||||
}
|
||||
-293
@@ -1,293 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.customizers;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
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.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
|
||||
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.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
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.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook invariants, the same for the notebook UI and the table UI / API:
|
||||
** - the owner is the creating session's user (only the qbit itself, as the
|
||||
** system user, may insert for someone else, e.g. a migration);
|
||||
** - visibility is derived from the shares (an ALL share = the library);
|
||||
** - currentVersion / createDate are system-managed; a title is required;
|
||||
** parameters are a JSON list;
|
||||
** - every content change appends a version;
|
||||
** - deleting a notebook deletes its cells, versions, pinned results and
|
||||
** shares (only after QQQ has authorized the delete).
|
||||
*******************************************************************************/
|
||||
public class NotebookTableCustomizer extends WorkbenchTableCustomizer
|
||||
{
|
||||
private static final List<String> CONTENT_FIELDS = List.of("title", "description", "defaultBackendName", "parametersJson");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
boolean system = isSystemUser();
|
||||
String userId = SqlWorkbenchSecurity.userId(QContext.getQSession());
|
||||
String userName = SqlWorkbenchSecurity.userName(QContext.getQSession());
|
||||
Instant now = clock().instant();
|
||||
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!system)
|
||||
{
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
denied(record, "The session has no user.");
|
||||
continue;
|
||||
}
|
||||
record.setValue("ownerUserId", userId);
|
||||
record.setValue("ownerName", userName);
|
||||
record.setValue("visibility", NotebookService.VISIBILITY_PERSONAL);
|
||||
record.setValue("currentVersion", 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if(!StringUtils.hasContent(record.getValueString("ownerUserId")))
|
||||
{
|
||||
badInput(record, "A notebook needs an owner.");
|
||||
}
|
||||
if(!StringUtils.hasContent(record.getValueString("visibility")))
|
||||
{
|
||||
record.setValue("visibility", NotebookService.VISIBILITY_PERSONAL);
|
||||
}
|
||||
if(record.getValue("currentVersion") == null)
|
||||
{
|
||||
record.setValue("currentVersion", 0);
|
||||
}
|
||||
}
|
||||
validateContent(record, true);
|
||||
record.setValue("createDate", now);
|
||||
record.setValue("modifyDate", now);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A new notebook made through the table gets version 1 (the notebook UI
|
||||
** writes its own, with the cells).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
if(!insertInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) && !insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(ok(record) && record.getValueInteger("id") != null)
|
||||
{
|
||||
ids.add(record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
appendVersions(ids, "Created", insertInput.getTransaction());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(isSystemUser() || updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
Instant now = clock().instant();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!ok(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QRecord old = oldRecord(oldRecordList, record);
|
||||
if(changes(record, old, "visibility"))
|
||||
{
|
||||
badInput(record, "Visibility follows the notebook's shares: share it with Everyone to publish it to the shared library.");
|
||||
continue;
|
||||
}
|
||||
if(changes(record, old, "ownerUserId"))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// an admin transfer (the lock allows nobody else): the name //
|
||||
// follows the new owner id, never a client-supplied value //
|
||||
////////////////////////////////////////////////////////////////
|
||||
record.setValue("ownerName", record.getValueString("ownerUserId"));
|
||||
}
|
||||
else
|
||||
{
|
||||
record.removeValue("ownerName");
|
||||
}
|
||||
record.removeValue("currentVersion");
|
||||
record.removeValue("createDate");
|
||||
validateContent(record, false);
|
||||
record.setValue("modifyDate", now);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A content edit made through the table appends a version.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(updateInput.hasFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE) || updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(ok(record) && record.getValueInteger("id") != null && CONTENT_FIELDS.stream().anyMatch(field -> record.getValues().containsKey(field)))
|
||||
{
|
||||
ids.add(record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
appendVersions(ids, "Edited", updateInput.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete the children of the notebooks QQQ authorized for deletion (the
|
||||
** foreign keys need them gone first). Inside the delete's transaction
|
||||
** when it has one (the notebook UI always does); a table-UI / API delete
|
||||
** has none, so there the children go first, in autocommit. A failed child
|
||||
** delete aborts the notebook delete.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
if(isPreview || deleteInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(ok(record) && record.getValue("id") != null)
|
||||
{
|
||||
ids.add(record.getValue("id"));
|
||||
}
|
||||
}
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
SqlWorkbenchQBitConfig config = config();
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME))
|
||||
{
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(config.applyPrefix(child))
|
||||
.withFlag(SqlWorkbenchActionFlag.INTERNAL)
|
||||
.withTransaction(deleteInput.getTransaction())
|
||||
.withQueryFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.IN, ids))));
|
||||
if(CollectionUtils.nullSafeHasContents(output.getRecordsWithErrors()))
|
||||
{
|
||||
throw (new QException("Could not delete the notebook's " + child + " rows: " + output.getRecordsWithErrors().get(0).getErrors().get(0).getMessage()));
|
||||
}
|
||||
}
|
||||
});
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Title (required on insert, never blank) and parameters (a JSON list).
|
||||
*******************************************************************************/
|
||||
private static void validateContent(QRecord record, boolean inserting)
|
||||
{
|
||||
if(inserting || record.getValues().containsKey("title"))
|
||||
{
|
||||
String title = record.getValueString("title");
|
||||
if(!StringUtils.hasContent(title))
|
||||
{
|
||||
badInput(record, "A notebook needs a title.");
|
||||
}
|
||||
else
|
||||
{
|
||||
record.setValue("title", title.trim());
|
||||
}
|
||||
}
|
||||
|
||||
String parameters = record.getValueString("parametersJson");
|
||||
if(StringUtils.hasContent(parameters))
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonUtils.toObject(parameters, new TypeReference<List<Object>>() {});
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
badInput(record, "Parameters must be a JSON list.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.customizers;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share invariants, the same for the notebook UI ("publish") and the table
|
||||
** UI / API:
|
||||
** - changing shares needs the share permission (the record lock then
|
||||
** checks the notebook is the caller's own, or the caller is an admin);
|
||||
** - USER and GROUP shares name their audience, ALL (the library) does not;
|
||||
** audienceKey is computed here and is what the read locks match;
|
||||
** - a share never moves to another notebook;
|
||||
** - the notebook's visibility follows its shares (SHARED while it has an
|
||||
** ALL share).
|
||||
*******************************************************************************/
|
||||
public class ShareTableCustomizer extends WorkbenchTableCustomizer
|
||||
{
|
||||
private static final String NEEDS_PERMISSION = "Sharing notebooks needs the share permission.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
boolean allowed = mayShare();
|
||||
QSession session = QContext.getQSession();
|
||||
Instant now = clock().instant();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!allowed)
|
||||
{
|
||||
denied(record, NEEDS_PERMISSION);
|
||||
continue;
|
||||
}
|
||||
if(record.getValueInteger("notebookId") == null)
|
||||
{
|
||||
badInput(record, "A share needs a notebook.");
|
||||
continue;
|
||||
}
|
||||
applyAudience(record, record.getValueString("audienceType"), record.getValueString("audienceId"));
|
||||
if(!isSystemUser() || !StringUtils.hasContent(record.getValueString("createdByUserId")))
|
||||
{
|
||||
record.setValue("createdByUserId", SqlWorkbenchSecurity.userId(session));
|
||||
record.setValue("createdByName", SqlWorkbenchSecurity.userName(session));
|
||||
}
|
||||
record.setValue("createDate", now);
|
||||
record.setValue("modifyDate", now);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
syncVisibility(insertInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL), notebookIds(records), insertInput.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
boolean allowed = mayShare();
|
||||
Instant now = clock().instant();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!ok(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!allowed)
|
||||
{
|
||||
denied(record, NEEDS_PERMISSION);
|
||||
continue;
|
||||
}
|
||||
QRecord old = oldRecord(oldRecordList, record);
|
||||
if(changes(record, old, "notebookId"))
|
||||
{
|
||||
badInput(record, "A share cannot move to another notebook.");
|
||||
continue;
|
||||
}
|
||||
record.removeValue("createdByUserId");
|
||||
record.removeValue("createdByName");
|
||||
record.removeValue("createDate");
|
||||
if(record.getValues().containsKey("audienceType") || record.getValues().containsKey("audienceId"))
|
||||
{
|
||||
String type = record.getValues().containsKey("audienceType") ? record.getValueString("audienceType") : old == null ? null : old.getValueString("audienceType");
|
||||
String id = record.getValues().containsKey("audienceId") ? record.getValueString("audienceId") : old == null ? null : old.getValueString("audienceId");
|
||||
applyAudience(record, type, id);
|
||||
}
|
||||
else
|
||||
{
|
||||
record.removeValue("audienceKey");
|
||||
}
|
||||
record.setValue("modifyDate", now);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = oldRecord(oldRecordList, record);
|
||||
if(ok(record) && old != null && old.getValueInteger("notebookId") != null)
|
||||
{
|
||||
ids.add(old.getValueInteger("notebookId"));
|
||||
}
|
||||
}
|
||||
syncVisibility(updateInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL), ids, updateInput.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
if(!mayShare())
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(ok(record))
|
||||
{
|
||||
denied(record, NEEDS_PERMISSION);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
syncVisibility(deleteInput.hasFlag(SqlWorkbenchActionFlag.INTERNAL), notebookIds(records), deleteInput.getTransaction());
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The system user, or a session holding the share permission.
|
||||
*******************************************************************************/
|
||||
private static boolean mayShare() throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
return (isSystemUser() || (session != null && session.hasPermission(config().getSharePermissionName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and normalize the audience; compute the audience key.
|
||||
*******************************************************************************/
|
||||
private static void applyAudience(QRecord record, String typeValue, String audienceId)
|
||||
{
|
||||
ShareAudienceType type = ShareAudienceType.of(typeValue);
|
||||
if(type == null)
|
||||
{
|
||||
badInput(record, "audienceType must be USER, GROUP or ALL.");
|
||||
return;
|
||||
}
|
||||
|
||||
record.setValue("audienceType", type.name());
|
||||
if(type == ShareAudienceType.ALL)
|
||||
{
|
||||
record.setValue("audienceId", null);
|
||||
}
|
||||
else if(!StringUtils.hasContent(audienceId))
|
||||
{
|
||||
badInput(record, type == ShareAudienceType.USER ? "Name the user to share with." : "Name the group to share with.");
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
record.setValue("audienceId", audienceId.trim());
|
||||
}
|
||||
record.setValue("audienceKey", type.audienceKey(audienceId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set each notebook's visibility from its shares: SHARED while it has an
|
||||
** ALL share, else PERSONAL (system user; the share change was already
|
||||
** authorized).
|
||||
*******************************************************************************/
|
||||
private static void syncVisibility(boolean internal, Set<Integer> notebookIds, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(internal || notebookIds.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SqlWorkbenchQBitConfig config = config();
|
||||
Instant now = clock().instant();
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
for(Integer notebookId : notebookIds)
|
||||
{
|
||||
Integer allShares = new CountAction().execute(new CountInput(config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME))
|
||||
.withTransaction(transaction)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId))
|
||||
.withCriteria(new QFilterCriteria("audienceKey", QCriteriaOperator.EQUALS, SqlWorkbenchSecurity.AUDIENCE_ALL)))).getCount();
|
||||
String visibility = allShares != null && allShares > 0 ? NotebookService.VISIBILITY_SHARED : NotebookService.VISIBILITY_PERSONAL;
|
||||
QRecord updated = new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.INTERNAL)
|
||||
.withTransaction(transaction)
|
||||
.withRecord(new QRecord().withValue("id", notebookId).withValue("visibility", visibility).withValue("modifyDate", now))).getRecords().get(0);
|
||||
if(!ok(updated))
|
||||
{
|
||||
LOG.warn("Could not sync a notebook's visibility with its shares", logPair("notebookId", notebookId), logPair("error", updated.getErrors().get(0).getMessage()));
|
||||
throw (new QException("Could not update the notebook's visibility: " + updated.getErrors().get(0).getMessage()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.customizers;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pinned results are created only by pinning a cached result from the
|
||||
** notebook UI (the data must come from a real run, never from a client);
|
||||
** through the table only the label can be edited, and the notebook's owner
|
||||
** can delete them (the record lock checks ownership through the notebook).
|
||||
*******************************************************************************/
|
||||
public class SnapshotTableCustomizer extends WorkbenchTableCustomizer
|
||||
{
|
||||
private static final Set<String> EDITABLE = Set.of("id", "label");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
if(!isSystemUser() && !insertInput.hasFlag(SqlWorkbenchActionFlag.SNAPSHOT_PIN))
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
denied(record, "Results are pinned from the notebook: run a cell, then pin its result.");
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(isSystemUser())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!ok(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord old = oldRecord(oldRecordList, record);
|
||||
for(String field : List.copyOf(record.getValues().keySet()))
|
||||
{
|
||||
if(!EDITABLE.contains(field) && changes(record, old, field))
|
||||
{
|
||||
badInput(record, "Only a pinned result's label can be edited.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.customizers;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Flags the qbit puts on its own table actions, so the customizers know a
|
||||
** write is part of a larger, already-validated operation. Flags are set in
|
||||
** Java only; no API or UI request can carry them.
|
||||
*******************************************************************************/
|
||||
public enum SqlWorkbenchActionFlag implements ActionFlag
|
||||
{
|
||||
/** a whole-notebook save from NotebookService: it writes one version itself */
|
||||
NOTEBOOK_SAVE,
|
||||
|
||||
/** pinning a cached result from the notebook UI (snapshot inserts) */
|
||||
SNAPSHOT_PIN,
|
||||
|
||||
/** the qbit's own bookkeeping (version bump, visibility sync, cascades) */
|
||||
INTERNAL
|
||||
}
|
||||
-281
@@ -1,281 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.customizers;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookVersioning;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared plumbing for the workbench table customizers.
|
||||
**
|
||||
** Pre-insert customizers run before QQQ's validations, so they can stamp
|
||||
** the owner / defaults the record locks and required-field checks then
|
||||
** verify. Update and delete validations (locks included) run before the
|
||||
** pre-update / pre-delete customizers, which skip records that already
|
||||
** carry errors and never rewrite a lock field.
|
||||
*******************************************************************************/
|
||||
public abstract class WorkbenchTableCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
protected static final QLogger LOG = QLogger.getLogger(WorkbenchTableCustomizer.class);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stamp defaults before QQQ validates (required fields, record locks).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput insertInput, boolean isPreview)
|
||||
{
|
||||
return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pass-through; subclasses override the hooks they need.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pass-through.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pass-through.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pass-through.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pass-through.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pass-through.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The old record for a record, by primary key "id".
|
||||
*******************************************************************************/
|
||||
protected static QRecord oldRecord(Optional<List<QRecord>> oldRecordList, QRecord record)
|
||||
{
|
||||
Serializable id = record.getValue("id");
|
||||
for(QRecord old : oldRecordList.orElse(List.of()))
|
||||
{
|
||||
if(id != null && Objects.equals(String.valueOf(id), String.valueOf(old.getValue("id"))))
|
||||
{
|
||||
return (old);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The installed workbench's configuration.
|
||||
*******************************************************************************/
|
||||
protected static SqlWorkbenchQBitConfig config() throws QException
|
||||
{
|
||||
return (SqlWorkbenchRuntime.get().getConfig());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench clock.
|
||||
*******************************************************************************/
|
||||
protected static Clock clock() throws QException
|
||||
{
|
||||
return (SqlWorkbenchRuntime.get().getClock());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True if the record has no errors yet.
|
||||
*******************************************************************************/
|
||||
protected static boolean ok(QRecord record)
|
||||
{
|
||||
return (CollectionUtils.nullSafeIsEmpty(record.getErrors()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a bad-input error to a record.
|
||||
*******************************************************************************/
|
||||
protected static void badInput(QRecord record, String message)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a permission-denied error to a record.
|
||||
*******************************************************************************/
|
||||
protected static void denied(QRecord record, String message)
|
||||
{
|
||||
record.addError(new PermissionDeniedMessage(message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True if the record sets the field to a value different from the old
|
||||
** record's (absent fields are unchanged).
|
||||
*******************************************************************************/
|
||||
protected static boolean changes(QRecord record, QRecord oldRecord, String field)
|
||||
{
|
||||
if(!record.getValues().containsKey(field))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Serializable oldValue = oldRecord == null ? null : oldRecord.getValue(field);
|
||||
return (!Objects.equals(normalize(record.getValue(field)), normalize(oldValue)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String normalize(Serializable value)
|
||||
{
|
||||
return (value == null ? null : value.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The distinct notebook ids of the error-free records.
|
||||
*******************************************************************************/
|
||||
protected static Set<Integer> notebookIds(List<QRecord> records)
|
||||
{
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
for(QRecord record : CollectionUtils.nonNullList(records))
|
||||
{
|
||||
if(ok(record) && record.getValueInteger("notebookId") != null)
|
||||
{
|
||||
ids.add(record.getValueInteger("notebookId"));
|
||||
}
|
||||
}
|
||||
return (ids);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append one version per notebook.
|
||||
*******************************************************************************/
|
||||
protected static void appendVersions(Set<Integer> notebookIds, String comment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
for(Integer notebookId : notebookIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
NotebookVersioning.appendVersion(config(), clock(), notebookId, comment, transaction);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// QQQ turns post-customizer exceptions into record warnings, so //
|
||||
// log here too: a change without its version must be visible //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
LOG.warn("Could not append a notebook version", e, logPair("notebookId", notebookId), logPair("comment", comment));
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for the qbit's own (system user) writes.
|
||||
*******************************************************************************/
|
||||
protected static boolean isSystemUser()
|
||||
{
|
||||
return (SqlWorkbenchSecurity.isSystemUser());
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.developer;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds read-only SELECT statements for a QQQ table in its RDBMS backend's
|
||||
** own names (the table's backend table name and each field's backend column
|
||||
** name, as QQQ's RDBMS module resolves them), with identifiers quoted and
|
||||
** the primary-key value written as an escaped literal for the dialect.
|
||||
**
|
||||
** The SQL is only shown to the user (and opened, not run, in the
|
||||
** workbench), so it uses literals rather than parameters.
|
||||
*******************************************************************************/
|
||||
public final class RecordSql
|
||||
{
|
||||
public static final int TABLE_ROW_LIMIT = 100;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private RecordSql()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SELECT the table's columns for one primary-key value.
|
||||
*******************************************************************************/
|
||||
public static String selectRecord(QTableMetaData table, SqlDialect dialect, Serializable primaryKey)
|
||||
{
|
||||
QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField());
|
||||
return (selectColumns(table, dialect)
|
||||
+ "\nWHERE " + quoteIdentifier(dialect, AbstractRDBMSAction.getColumnName(primaryKeyField)) + " = " + literal(dialect, primaryKeyField.getType(), primaryKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SELECT the table's columns, first rows only.
|
||||
*******************************************************************************/
|
||||
public static String selectTable(QTableMetaData table, SqlDialect dialect)
|
||||
{
|
||||
String select = selectColumns(table, dialect);
|
||||
if(dialect == SqlDialect.GENERIC)
|
||||
{
|
||||
return (select + "\nFETCH FIRST " + TABLE_ROW_LIMIT + " ROWS ONLY");
|
||||
}
|
||||
return (select + "\nLIMIT " + TABLE_ROW_LIMIT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SELECT clause listing every field's column (aliased to the QQQ field
|
||||
** name where the names differ) and the FROM clause.
|
||||
*******************************************************************************/
|
||||
static String selectColumns(QTableMetaData table, SqlDialect dialect)
|
||||
{
|
||||
List<String> columns = new ArrayList<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String column = AbstractRDBMSAction.getColumnName(field);
|
||||
columns.add(column.equals(field.getName())
|
||||
? quoteIdentifier(dialect, column)
|
||||
: quoteIdentifier(dialect, column) + " AS " + quoteIdentifier(dialect, field.getName()));
|
||||
}
|
||||
|
||||
return ("SELECT\n " + String.join(",\n ", columns)
|
||||
+ "\nFROM " + quoteIdentifier(dialect, AbstractRDBMSAction.getTableName(table)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quote an identifier: backticks for MySQL, double quotes otherwise (ANSI;
|
||||
** Postgres, SQLite and H2 all accept them). A quote character inside the
|
||||
** identifier is doubled.
|
||||
*******************************************************************************/
|
||||
public static String quoteIdentifier(SqlDialect dialect, String identifier)
|
||||
{
|
||||
String quote = dialect == SqlDialect.MYSQL ? "`" : "\"";
|
||||
return (quote + identifier.replace(quote, quote + quote) + quote);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A value as a SQL literal: NULL; a plain number for numeric fields whose
|
||||
** value parses as one; otherwise a single-quoted string with quotes
|
||||
** doubled (and, for MySQL, backslashes escaped).
|
||||
*******************************************************************************/
|
||||
public static String literal(SqlDialect dialect, QFieldType type, Serializable value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("NULL");
|
||||
}
|
||||
|
||||
if(type == QFieldType.INTEGER || type == QFieldType.LONG || type == QFieldType.DECIMAL || value instanceof Number)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new BigDecimal(value.toString().trim()).toPlainString());
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////
|
||||
// not a number after all: fall through to a string //
|
||||
///////////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
|
||||
return (stringLiteral(dialect, value.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A single-quoted string literal.
|
||||
*******************************************************************************/
|
||||
public static String stringLiteral(SqlDialect dialect, String value)
|
||||
{
|
||||
String escaped = value;
|
||||
if(dialect.isBackslashEscapes())
|
||||
{
|
||||
escaped = escaped.replace("\\", "\\\\");
|
||||
}
|
||||
return ("'" + escaped.replace("'", "''") + "'");
|
||||
}
|
||||
|
||||
}
|
||||
-269
@@ -1,269 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.developer;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelLink;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelTone;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record developer panel "SQL Workbench": read-only SELECTs for the record
|
||||
** (and its table) in the backend's own table and column names, with links
|
||||
** that open them in the workbench as new cells (never run).
|
||||
**
|
||||
** Available when the workbench is in the instance (its runtime and app), the
|
||||
** user has the workbench access permission (the same check its endpoints
|
||||
** make), and the table's backend is an RDBMS backend the workbench allows.
|
||||
** The panel only builds text: nothing is executed, and the user's write
|
||||
** permission makes no difference.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchDeveloperPanelProvider implements DeveloperPanelProviderInterface
|
||||
{
|
||||
public static final String ID = "sqlWorkbench";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getId()
|
||||
{
|
||||
return (ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getTitle()
|
||||
{
|
||||
return ("SQL Workbench");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer getOrder()
|
||||
{
|
||||
return (450);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getIcon()
|
||||
{
|
||||
return ("terminal");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Available for a table on an allowed RDBMS backend, to users with the
|
||||
** workbench access permission, when the workbench app exists.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isAvailable(DeveloperPanelContext context)
|
||||
{
|
||||
return (resolve(context) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public DeveloperPanel build(DeveloperPanelContext context) throws QException
|
||||
{
|
||||
Resolved resolved = resolve(context);
|
||||
if(resolved == null)
|
||||
{
|
||||
throw (new QNotFoundException("The SQL Workbench is not available for this table."));
|
||||
}
|
||||
|
||||
QTableMetaData table = context.getTable();
|
||||
WorkbenchBackends.Target target = resolved.target();
|
||||
String runnerApp = resolved.config().applyPrefix(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME);
|
||||
Serializable primaryKey = context.getPrimaryKey();
|
||||
|
||||
DeveloperPanel panel = new DeveloperPanel()
|
||||
.withDescription("Read-only SQL for this record on the " + target.name() + " backend, in the database's own table and column names.")
|
||||
.withStatusLabel("Read-only")
|
||||
.withStatusTone(DeveloperPanelTone.INFO);
|
||||
|
||||
DeveloperPanelBlock links = DeveloperPanelBlock.links("Open in SQL Workbench");
|
||||
|
||||
if(primaryKey != null)
|
||||
{
|
||||
String recordSql = RecordSql.selectRecord(table, target.dialect(), primaryKey);
|
||||
panel.withBlock(DeveloperPanelBlock.code("Read-only query for this record", "sql", recordSql));
|
||||
links.withLink(new DeveloperPanelLink()
|
||||
.withLabel("Open in SQL Workbench")
|
||||
.withHref(workbenchHref(runnerApp, recordSql, target.name()))
|
||||
.withDescription("This record's query, as a new cell (not run)"));
|
||||
}
|
||||
|
||||
String tableSql = RecordSql.selectTable(table, target.dialect());
|
||||
panel.withBlock(DeveloperPanelBlock.code("Read-only query for this table (first " + RecordSql.TABLE_ROW_LIMIT + " rows)", "sql", tableSql));
|
||||
links.withLink(new DeveloperPanelLink()
|
||||
.withLabel(primaryKey == null ? "Open in SQL Workbench" : "Open table query in SQL Workbench")
|
||||
.withHref(workbenchHref(runnerApp, tableSql, target.name()))
|
||||
.withDescription("The table's query, as a new cell (not run)"));
|
||||
|
||||
panel.withBlock(DeveloperPanelBlock.keyValues("Backend")
|
||||
.withItem("Backend", target.name())
|
||||
.withItem("Dialect", target.dialect().getUiName())
|
||||
.withItem("Database table", AbstractRDBMSAction.getTableName(table))
|
||||
.withItem("Read-only replica", describeReadOnlySource(resolved)));
|
||||
|
||||
panel.withBlock(links);
|
||||
|
||||
panel.withBlock(DeveloperPanelBlock.text("The workbench runs only what you execute, under your SQL Workbench permissions. "
|
||||
+ "These queries are SELECTs; opening them does not run them.", DeveloperPanelTone.INFO));
|
||||
|
||||
return (panel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runner page with the SQL to open (as a new cell, never run) and its
|
||||
** backend.
|
||||
*******************************************************************************/
|
||||
static String workbenchHref(String appName, String sql, String backendName)
|
||||
{
|
||||
return ("/app/" + encode(appName) + "?sql=" + encode(sql) + "&backend=" + encode(backendName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** URL-encode a query parameter (spaces as %20, not '+').
|
||||
*******************************************************************************/
|
||||
static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where read-only workbench sessions on the target connect, as
|
||||
** WorkbenchBackends.connectionSource resolves it.
|
||||
*******************************************************************************/
|
||||
private String describeReadOnlySource(Resolved resolved)
|
||||
{
|
||||
if(resolved.backends().hasReadOnlyCredentials(resolved.target()))
|
||||
{
|
||||
return ("No (dedicated read-only credentials)");
|
||||
}
|
||||
if(Boolean.TRUE.equals(resolved.config().getUseBackendReadOnlyReplica()) && resolved.target().backend().getReadOnlyBackendMetaData() != null)
|
||||
{
|
||||
return ("Yes (" + resolved.target().backend().getReadOnlyBackendMetaData().getName() + ")");
|
||||
}
|
||||
return ("No");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench config and target for the context's table, or null when
|
||||
** the panel is not available.
|
||||
*******************************************************************************/
|
||||
private Resolved resolve(DeveloperPanelContext context)
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
QSession session = QContext.getQSession();
|
||||
if(qInstance == null || session == null || context == null || context.getTable() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
QTableMetaData table = context.getTable();
|
||||
if(!StringUtils.hasContent(table.getBackendName()) || !StringUtils.hasContent(table.getPrimaryKeyField()) || table.getField(table.getPrimaryKeyField()) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
SqlWorkbenchRuntime runtime;
|
||||
try
|
||||
{
|
||||
runtime = SqlWorkbenchRuntime.get(qInstance);
|
||||
}
|
||||
catch(QNotFoundException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
SqlWorkbenchQBitConfig config = runtime.getConfig();
|
||||
if(qInstance.getApp(config.applyPrefix(SqlWorkbenchAppMetaDataProducer.APP_NAME)) == null || !session.hasPermission(config.getAccessPermissionName()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
WorkbenchBackends backends = new WorkbenchBackends(qInstance, runtime);
|
||||
try
|
||||
{
|
||||
return (new Resolved(config, backends, backends.getTarget(table.getBackendName())));
|
||||
}
|
||||
catch(QNotFoundException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench config, backends and target for a table.
|
||||
*******************************************************************************/
|
||||
private record Resolved(SqlWorkbenchQBitConfig config, WorkbenchBackends backends, WorkbenchBackends.Target target)
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
/*******************************************************************************
|
||||
** Who is calling, and what they may do (resolved from the QSession by the
|
||||
** API layer).
|
||||
**
|
||||
** @param userId the session user's idReference (owner key everywhere)
|
||||
** @param userName display name
|
||||
** @param canWrite has the write permission (and write mode is enabled)
|
||||
** @param canShare has the share permission
|
||||
** @param clientIp request address, when known
|
||||
*******************************************************************************/
|
||||
public record Caller(String userId, String userName, boolean canWrite, boolean canShare, String clientIp)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The Caller of a session (which must already be authenticated and hold
|
||||
** the access permission - the API and the record actions check that).
|
||||
*******************************************************************************/
|
||||
public static Caller fromSession(SqlWorkbenchQBitConfig config, QSession session, String clientIp)
|
||||
{
|
||||
String userId = SqlWorkbenchSecurity.userId(session);
|
||||
boolean canWrite = Boolean.TRUE.equals(config.getWriteModeEnabled()) && session.hasPermission(config.getWritePermissionName());
|
||||
boolean canShare = session.hasPermission(config.getSharePermissionName());
|
||||
return (new Caller(userId, SqlWorkbenchSecurity.userName(session), canWrite, canShare, clientIp));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A result set, read into memory within the configured limits.
|
||||
**
|
||||
** @param columns the columns
|
||||
** @param rows the rows (values already converted to JSON-safe
|
||||
** types by ResultCollector)
|
||||
** @param truncated whether a limit stopped collection
|
||||
** @param truncatedReason ROWS or BYTES when truncated, else null
|
||||
** @param estimatedBytes estimated size of the collected values
|
||||
*******************************************************************************/
|
||||
public record CollectedResult(List<ColumnInfo> columns, List<List<Object>> rows, boolean truncated, String truncatedReason, long estimatedBytes)
|
||||
{
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One result column.
|
||||
**
|
||||
** @param name column name from the driver
|
||||
** @param label column label (alias) from the driver
|
||||
** @param typeName database type name (e.g. int4, jsonb, VARCHAR)
|
||||
** @param jdbcType java.sql.Types code
|
||||
** @param kind how the UI should render it: number, string, boolean,
|
||||
** date, datetime, time, json, binary, array, other
|
||||
*******************************************************************************/
|
||||
public record ColumnInfo(String name, String label, String typeName, int jdbcType, String kind)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON view of this column.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> toMap()
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", name);
|
||||
map.put("label", label);
|
||||
map.put("typeName", typeName);
|
||||
map.put("jdbcType", jdbcType);
|
||||
map.put("kind", kind);
|
||||
return (map);
|
||||
}
|
||||
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client-chosen executionUuid that is already running or already in the
|
||||
** audit. Execution ids are single-use; the route provider answers 409.
|
||||
*******************************************************************************/
|
||||
public class DuplicateExecutionException extends QBadRequestException
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public DuplicateExecutionException(String executionUuid)
|
||||
{
|
||||
super("executionUuid " + executionUuid + " was already used; send a new UUID for each run.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes the execution audit: a RUNNING row before the SQL runs (if that
|
||||
** fails, the SQL does not run) and the outcome after. Writes go through QQQ
|
||||
** actions in a system-session context, because the audit table's insert
|
||||
** and edit capabilities are disabled for users.
|
||||
*******************************************************************************/
|
||||
public class ExecutionAuditor
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(ExecutionAuditor.class);
|
||||
|
||||
public static final int MAX_ERROR_LENGTH = 4000;
|
||||
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ExecutionAuditor(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The audit table's (possibly prefixed) name.
|
||||
*******************************************************************************/
|
||||
public String tableName()
|
||||
{
|
||||
return (config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert the RUNNING row.
|
||||
**
|
||||
** @return the row's id
|
||||
*******************************************************************************/
|
||||
public Integer start(SqlWorkbenchExecution execution) throws QException
|
||||
{
|
||||
execution.setStatus(ExecutionStatus.RUNNING.name());
|
||||
execution.setCreateDate(execution.getStartTime());
|
||||
QRecord[] inserted = new QRecord[1];
|
||||
asSystem(() ->
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(tableName()).withRecordEntity(execution));
|
||||
inserted[0] = output.getRecords().get(0);
|
||||
});
|
||||
|
||||
throwIfNotInserted(inserted[0], "Could not write the SQL Workbench audit record; the SQL was not run.");
|
||||
execution.setId(inserted[0].getValueInteger("id"));
|
||||
return (execution.getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the outcome.
|
||||
*******************************************************************************/
|
||||
public void finish(SqlWorkbenchExecution execution, ExecutionStatus status, Instant endTime, Integer rowsReturned, Long rowsAffected, Boolean truncated, String errorMessage) throws QException
|
||||
{
|
||||
execution.setStatus(status.name());
|
||||
execution.setEndTime(endTime);
|
||||
execution.setDurationMs(execution.getStartTime() == null ? null : (int) Math.min(Integer.MAX_VALUE, Duration.between(execution.getStartTime(), endTime).toMillis()));
|
||||
execution.setRowsReturned(rowsReturned);
|
||||
execution.setRowsAffected(rowsAffected == null ? null : (int) Math.min(Integer.MAX_VALUE, rowsAffected));
|
||||
execution.setTruncated(truncated);
|
||||
execution.setErrorMessage(truncate(errorMessage));
|
||||
|
||||
QRecord record = new QRecord()
|
||||
.withValue("id", execution.getId())
|
||||
.withValue("status", execution.getStatus())
|
||||
.withValue("endTime", execution.getEndTime())
|
||||
.withValue("durationMs", execution.getDurationMs())
|
||||
.withValue("rowsReturned", execution.getRowsReturned())
|
||||
.withValue("rowsAffected", execution.getRowsAffected())
|
||||
.withValue("truncated", execution.getTruncated())
|
||||
.withValue("errorMessage", execution.getErrorMessage());
|
||||
|
||||
asSystem(() -> new UpdateAction().execute(new UpdateInput(tableName()).withRecords(List.of(record))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a complete row for a run that never started (e.g. rejected by
|
||||
** validation or permissions).
|
||||
*******************************************************************************/
|
||||
public void record(SqlWorkbenchExecution execution) throws QException
|
||||
{
|
||||
execution.setCreateDate(execution.getStartTime());
|
||||
execution.setErrorMessage(truncate(execution.getErrorMessage()));
|
||||
QRecord[] inserted = new QRecord[1];
|
||||
asSystem(() -> inserted[0] = new InsertAction().execute(new InsertInput(tableName()).withRecordEntity(execution)).getRecords().get(0));
|
||||
throwIfNotInserted(inserted[0], "Could not write the SQL Workbench audit record of a rejected request.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the audit already holds a row with this execution id.
|
||||
*******************************************************************************/
|
||||
public boolean exists(String executionUuid) throws QException
|
||||
{
|
||||
return (SystemUser.call(() -> !QueryAction.execute(tableName(), new QQueryFilter(new QFilterCriteria("executionUuid", QCriteriaOperator.EQUALS, executionUuid)).withLimit(1)).isEmpty()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail loudly when an audit insert did not produce a row (record errors,
|
||||
** e.g. a duplicate executionUuid, or no generated id).
|
||||
*******************************************************************************/
|
||||
private static void throwIfNotInserted(QRecord record, String message) throws QException
|
||||
{
|
||||
if(record == null || (record.getErrors() != null && !record.getErrors().isEmpty()) || record.getValueInteger("id") == null)
|
||||
{
|
||||
String detail = record == null || record.getErrors() == null || record.getErrors().isEmpty() ? "no id returned" : record.getErrors().get(0).getMessage();
|
||||
LOG.warn(message, logPair("detail", detail));
|
||||
throw (new QException(message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String truncate(String message)
|
||||
{
|
||||
if(message == null || message.length() <= MAX_ERROR_LENGTH)
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
return (message.substring(0, MAX_ERROR_LENGTH - 1) + "…");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run as the system user, in the current instance.
|
||||
*******************************************************************************/
|
||||
static void asSystem(SystemUser.Block block) throws QException
|
||||
{
|
||||
SystemUser.run(block);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How an execution ran, as recorded in the audit.
|
||||
*******************************************************************************/
|
||||
public enum ExecutionMode
|
||||
{
|
||||
READ_ONLY,
|
||||
WRITE_PREVIEW,
|
||||
WRITE_COMMIT,
|
||||
EXPLAIN,
|
||||
EXPLAIN_ANALYZE
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A request to run SQL.
|
||||
**
|
||||
** @param backendName target backend
|
||||
** @param sql the SQL text
|
||||
** @param params values for :name parameters (JSON types)
|
||||
** @param write true for write mode
|
||||
** @param notebookId where it ran from (optional)
|
||||
** @param cellKey which cell (optional)
|
||||
** @param pageSize rows in the first page (optional)
|
||||
** @param confirmToken write mode: the preview's token (commit)
|
||||
** @param acknowledgeRowGuard write mode: the caller accepted the row guard
|
||||
** @param executionUuid optional client-chosen id (a UUID), so the client
|
||||
** can cancel the run before it answers; generated
|
||||
** when absent
|
||||
*******************************************************************************/
|
||||
public record ExecutionRequest(String backendName, String sql, Map<String, Object> params, boolean write, Integer notebookId, String cellKey,
|
||||
Integer pageSize, String confirmToken, boolean acknowledgeRowGuard, String executionUuid)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a client-chosen execution id.
|
||||
*******************************************************************************/
|
||||
public ExecutionRequest(String backendName, String sql, Map<String, Object> params, boolean write, Integer notebookId, String cellKey,
|
||||
Integer pageSize, String confirmToken, boolean acknowledgeRowGuard)
|
||||
{
|
||||
this(backendName, sql, params, write, notebookId, cellKey, pageSize, confirmToken, acknowledgeRowGuard, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only request.
|
||||
*******************************************************************************/
|
||||
public static ExecutionRequest readOnly(String backendName, String sql, Map<String, Object> params)
|
||||
{
|
||||
return (new ExecutionRequest(backendName, sql, params, false, null, null, null, null, false));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of an execution, as recorded in the audit.
|
||||
*******************************************************************************/
|
||||
public enum ExecutionStatus
|
||||
{
|
||||
RUNNING,
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
TIMED_OUT,
|
||||
REJECTED,
|
||||
ROLLED_BACK,
|
||||
DRIFTED
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** In-memory, owner-scoped cache of collected results, so the UI can page,
|
||||
** sort and export without re-running SQL. Bounded by entry count (LRU) and
|
||||
** TTL. Entries belong to the user who ran the SQL; any other user gets
|
||||
** nothing (and the API turns that into a 404).
|
||||
*******************************************************************************/
|
||||
public class ResultCache
|
||||
{
|
||||
private final int maxEntries;
|
||||
private final long ttlMillis;
|
||||
private final Clock clock;
|
||||
|
||||
private final LinkedHashMap<String, Entry> entries = new LinkedHashMap<>(16, 0.75f, true);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One cached result.
|
||||
**
|
||||
** @param executionUuid the execution's id
|
||||
** @param userId owner
|
||||
** @param backendName where it ran
|
||||
** @param result the collected result
|
||||
** @param createdAt when it was cached
|
||||
*******************************************************************************/
|
||||
public record Entry(String executionUuid, String userId, String backendName, CollectedResult result, Instant createdAt)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ResultCache(int maxEntries, int ttlSeconds, Clock clock)
|
||||
{
|
||||
this.maxEntries = maxEntries;
|
||||
this.ttlMillis = ttlSeconds * 1000L;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store a result, evicting expired and least recently used entries.
|
||||
*******************************************************************************/
|
||||
public synchronized void put(String executionUuid, String userId, String backendName, CollectedResult result)
|
||||
{
|
||||
purgeExpired();
|
||||
entries.put(executionUuid, new Entry(executionUuid, userId, backendName, result, clock.instant()));
|
||||
while(entries.size() > maxEntries)
|
||||
{
|
||||
String eldest = entries.keySet().iterator().next();
|
||||
entries.remove(eldest);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get a result, only for its owner and only while it has not expired.
|
||||
*******************************************************************************/
|
||||
public synchronized Optional<Entry> get(String executionUuid, String userId)
|
||||
{
|
||||
purgeExpired();
|
||||
Entry entry = entries.get(executionUuid);
|
||||
if(entry == null || !Objects.equals(entry.userId(), userId))
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
return (Optional.of(entry));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public synchronized int size()
|
||||
{
|
||||
purgeExpired();
|
||||
return (entries.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public synchronized void clear()
|
||||
{
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void purgeExpired()
|
||||
{
|
||||
Instant cutoff = clock.instant().minusMillis(ttlMillis);
|
||||
entries.values().removeIf(e -> e.createdAt().isBefore(cutoff));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows of a result, optionally sorted by one column (type-aware, nulls
|
||||
** last), then sliced.
|
||||
*******************************************************************************/
|
||||
public static List<List<Object>> page(CollectedResult result, int offset, int limit, Integer sortColumn, boolean descending)
|
||||
{
|
||||
List<List<Object>> rows = result.rows();
|
||||
if(sortColumn != null && sortColumn >= 0 && sortColumn < result.columns().size())
|
||||
{
|
||||
rows = new ArrayList<>(rows);
|
||||
int column = sortColumn;
|
||||
rows.sort((a, b) -> compareNullsLast(a.get(column), b.get(column), descending));
|
||||
}
|
||||
|
||||
int from = Math.max(0, Math.min(offset, rows.size()));
|
||||
int to = Math.max(from, Math.min(rows.size(), from + Math.max(0, limit)));
|
||||
return (new ArrayList<>(rows.subList(from, to)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare two values, nulls last in either direction.
|
||||
*******************************************************************************/
|
||||
static int compareNullsLast(Object a, Object b, boolean descending)
|
||||
{
|
||||
if(a == null || b == null)
|
||||
{
|
||||
return (a == null ? (b == null ? 0 : 1) : -1);
|
||||
}
|
||||
return (descending ? compareValues(b, a) : compareValues(a, b));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare two non-null converted values: numbers numerically (numeric
|
||||
** strings too), booleans, else as strings.
|
||||
*******************************************************************************/
|
||||
static int compareValues(Object a, Object b)
|
||||
{
|
||||
BigDecimal na = asNumber(a);
|
||||
BigDecimal nb = asNumber(b);
|
||||
if(na != null && nb != null)
|
||||
{
|
||||
return (na.compareTo(nb));
|
||||
}
|
||||
if(a instanceof Boolean ba && b instanceof Boolean bb)
|
||||
{
|
||||
return (ba.compareTo(bb));
|
||||
}
|
||||
return (String.valueOf(a).compareTo(String.valueOf(b)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static BigDecimal asNumber(Object value)
|
||||
{
|
||||
if(value instanceof BigDecimal bd)
|
||||
{
|
||||
return (bd);
|
||||
}
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (new BigDecimal(n.toString()));
|
||||
}
|
||||
if(value instanceof String s && !s.isEmpty() && s.length() < 64)
|
||||
{
|
||||
char c = s.charAt(0);
|
||||
if(Character.isDigit(c) || c == '-' || c == '+' || c == '.')
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new BigDecimal(s));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For tests and diagnostics: the cached execution ids, oldest first.
|
||||
*******************************************************************************/
|
||||
synchronized List<String> keys()
|
||||
{
|
||||
return (new ArrayList<>(entries.keySet()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.sql.Array;
|
||||
import java.sql.Blob;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Date;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Time;
|
||||
import java.sql.Timestamp;
|
||||
import java.sql.Types;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads a ResultSet into JSON-safe values, stopping at the row cap or the
|
||||
** byte budget.
|
||||
**
|
||||
** Conversions: null stays null; integers and decimals within +/-2^53 are
|
||||
** numbers, wider ones are strings (no precision loss in JavaScript);
|
||||
** dates/times are ISO-8601 strings; json/jsonb are strings (kind json);
|
||||
** binary is {"$binary": true, "length": n, "hexPreview": "..."}; arrays are
|
||||
** lists; anything else is its toString, capped at maxCellChars.
|
||||
*******************************************************************************/
|
||||
public class ResultCollector
|
||||
{
|
||||
private static final BigDecimal MAX_SAFE = BigDecimal.valueOf(9007199254740991L);
|
||||
private static final int HEX_PREVIEW_BYTES = 32;
|
||||
|
||||
private final int maxRows;
|
||||
private final long maxBytes;
|
||||
private final int maxCellChars;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ResultCollector(int maxRows, long maxBytes, int maxCellChars)
|
||||
{
|
||||
this.maxRows = maxRows;
|
||||
this.maxBytes = maxBytes;
|
||||
this.maxCellChars = maxCellChars;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the columns of a result set.
|
||||
*******************************************************************************/
|
||||
public static List<ColumnInfo> readColumns(ResultSetMetaData metaData) throws SQLException
|
||||
{
|
||||
List<ColumnInfo> columns = new ArrayList<>();
|
||||
for(int i = 1; i <= metaData.getColumnCount(); i++)
|
||||
{
|
||||
String typeName = metaData.getColumnTypeName(i);
|
||||
int jdbcType = metaData.getColumnType(i);
|
||||
columns.add(new ColumnInfo(metaData.getColumnName(i), metaData.getColumnLabel(i), typeName, jdbcType, kindOf(jdbcType, typeName)));
|
||||
}
|
||||
return (columns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collect rows until the result ends, maxRows rows are read (then one more
|
||||
** row is probed to detect truncation), or the byte budget runs out.
|
||||
*******************************************************************************/
|
||||
public CollectedResult collect(ResultSet resultSet) throws SQLException
|
||||
{
|
||||
List<ColumnInfo> columns = readColumns(resultSet.getMetaData());
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
long bytes = 0;
|
||||
|
||||
while(resultSet.next())
|
||||
{
|
||||
if(rows.size() >= maxRows)
|
||||
{
|
||||
return (new CollectedResult(columns, rows, true, "ROWS", bytes));
|
||||
}
|
||||
|
||||
List<Object> row = new ArrayList<>(columns.size());
|
||||
for(int i = 0; i < columns.size(); i++)
|
||||
{
|
||||
Object value = readValue(resultSet, i + 1, columns.get(i));
|
||||
bytes += estimateBytes(value);
|
||||
row.add(value);
|
||||
}
|
||||
|
||||
if(bytes > maxBytes)
|
||||
{
|
||||
return (new CollectedResult(columns, rows, true, "BYTES", bytes));
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
return (new CollectedResult(columns, rows, false, null, bytes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a JDBC type to a UI rendering kind.
|
||||
*******************************************************************************/
|
||||
static String kindOf(int jdbcType, String typeName)
|
||||
{
|
||||
String type = typeName == null ? "" : typeName.toLowerCase(Locale.ROOT);
|
||||
if(type.equals("json") || type.equals("jsonb"))
|
||||
{
|
||||
return ("json");
|
||||
}
|
||||
|
||||
return switch(jdbcType)
|
||||
{
|
||||
case Types.TINYINT, Types.SMALLINT, Types.INTEGER, Types.BIGINT, Types.FLOAT, Types.REAL, Types.DOUBLE, Types.NUMERIC, Types.DECIMAL -> "number";
|
||||
case Types.BIT, Types.BOOLEAN -> type.startsWith("bit") && !type.equals("bit") ? "string" : "boolean";
|
||||
case Types.DATE -> "date";
|
||||
case Types.TIME, Types.TIME_WITH_TIMEZONE -> "time";
|
||||
case Types.TIMESTAMP, Types.TIMESTAMP_WITH_TIMEZONE -> "datetime";
|
||||
case Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY, Types.BLOB -> "binary";
|
||||
case Types.ARRAY -> "array";
|
||||
case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR, Types.CLOB, Types.NCLOB -> "string";
|
||||
default -> "other";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read and convert one value.
|
||||
*******************************************************************************/
|
||||
Object readValue(ResultSet resultSet, int index, ColumnInfo column) throws SQLException
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// pgjdbc reports timestamptz as TIMESTAMP; read it with its offset //
|
||||
// rather than shifting it into the JVM's zone //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
int jdbcType = column.jdbcType() == Types.TIMESTAMP && isZoned(column.typeName()) ? Types.TIMESTAMP_WITH_TIMEZONE : column.jdbcType();
|
||||
switch(jdbcType)
|
||||
{
|
||||
case Types.DATE ->
|
||||
{
|
||||
Object raw = resultSet.getObject(index);
|
||||
if(raw == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (raw instanceof Date date ? date.toLocalDate().toString() : convert(raw));
|
||||
}
|
||||
case Types.TIME ->
|
||||
{
|
||||
Object raw = resultSet.getObject(index);
|
||||
if(raw == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (raw instanceof Time time ? time.toLocalTime().toString() : convert(raw));
|
||||
}
|
||||
case Types.TIMESTAMP ->
|
||||
{
|
||||
Object raw = resultSet.getObject(index);
|
||||
if(raw == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (raw instanceof Timestamp timestamp ? timestamp.toLocalDateTime().toString() : convert(raw));
|
||||
}
|
||||
case Types.TIMESTAMP_WITH_TIMEZONE ->
|
||||
{
|
||||
try
|
||||
{
|
||||
OffsetDateTime value = resultSet.getObject(index, OffsetDateTime.class);
|
||||
return (value == null ? null : value.toString());
|
||||
}
|
||||
catch(SQLException | RuntimeException e)
|
||||
{
|
||||
return (convert(resultSet.getObject(index)));
|
||||
}
|
||||
}
|
||||
default ->
|
||||
{
|
||||
if("json".equals(column.kind()))
|
||||
{
|
||||
return (capString(resultSet.getString(index)));
|
||||
}
|
||||
return (convert(resultSet.getObject(index)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a type name is a zoned timestamp (timestamptz, timestamp with
|
||||
** time zone).
|
||||
*******************************************************************************/
|
||||
static boolean isZoned(String typeName)
|
||||
{
|
||||
String type = typeName == null ? "" : typeName.toLowerCase(Locale.ROOT);
|
||||
return (type.equals("timestamptz") || type.contains("with time zone"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert a driver value to a JSON-safe value.
|
||||
*******************************************************************************/
|
||||
Object convert(Object value) throws SQLException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(value instanceof Boolean || value instanceof Integer || value instanceof Short || value instanceof Byte)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
if(value instanceof Long l)
|
||||
{
|
||||
return (Math.abs(l) <= MAX_SAFE.longValue() ? l : l.toString());
|
||||
}
|
||||
if(value instanceof BigInteger bi)
|
||||
{
|
||||
return (bi.abs().compareTo(MAX_SAFE.toBigInteger()) <= 0 ? bi.longValue() : bi.toString());
|
||||
}
|
||||
if(value instanceof BigDecimal bd)
|
||||
{
|
||||
boolean safe = bd.abs().compareTo(MAX_SAFE) <= 0 && bd.precision() <= 15;
|
||||
return (safe ? bd : bd.toPlainString());
|
||||
}
|
||||
if(value instanceof Double d)
|
||||
{
|
||||
return (d.isNaN() || d.isInfinite() ? d.toString() : d);
|
||||
}
|
||||
if(value instanceof Float f)
|
||||
{
|
||||
return (f.isNaN() || f.isInfinite() ? f.toString() : f.doubleValue());
|
||||
}
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (n.toString());
|
||||
}
|
||||
if(value instanceof byte[] bytes)
|
||||
{
|
||||
return (binary(bytes, bytes.length));
|
||||
}
|
||||
if(value instanceof Blob blob)
|
||||
{
|
||||
long length = blob.length();
|
||||
return (binary(blob.getBytes(1, (int) Math.min(length, HEX_PREVIEW_BYTES)), length));
|
||||
}
|
||||
if(value instanceof Clob clob)
|
||||
{
|
||||
long length = clob.length();
|
||||
return (capString(clob.getSubString(1, (int) Math.min(length, maxCellChars + 1L))));
|
||||
}
|
||||
if(value instanceof Array array)
|
||||
{
|
||||
Object raw = array.getArray();
|
||||
List<Object> list = new ArrayList<>();
|
||||
if(raw instanceof Object[] objects)
|
||||
{
|
||||
for(Object o : objects)
|
||||
{
|
||||
list.add(convert(o));
|
||||
}
|
||||
}
|
||||
return (list);
|
||||
}
|
||||
if(value instanceof Timestamp timestamp)
|
||||
{
|
||||
return (timestamp.toLocalDateTime().toString());
|
||||
}
|
||||
if(value instanceof Date date)
|
||||
{
|
||||
return (date.toLocalDate().toString());
|
||||
}
|
||||
if(value instanceof Time time)
|
||||
{
|
||||
return (time.toLocalTime().toString());
|
||||
}
|
||||
if(value instanceof TemporalAccessor)
|
||||
{
|
||||
return (value.toString());
|
||||
}
|
||||
return (capString(value.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> binary(byte[] bytes, long length)
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("$binary", true);
|
||||
map.put("length", length);
|
||||
byte[] preview = bytes.length > HEX_PREVIEW_BYTES ? java.util.Arrays.copyOf(bytes, HEX_PREVIEW_BYTES) : bytes;
|
||||
map.put("hexPreview", HexFormat.of().formatHex(preview));
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String capString(String value)
|
||||
{
|
||||
if(value == null || value.length() <= maxCellChars)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
return (value.substring(0, maxCellChars) + "…");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rough in-memory / on-the-wire size of a converted value.
|
||||
*******************************************************************************/
|
||||
static long estimateBytes(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (4);
|
||||
}
|
||||
if(value instanceof String s)
|
||||
{
|
||||
return (16L + 2L * s.length());
|
||||
}
|
||||
if(value instanceof List<?> list)
|
||||
{
|
||||
long total = 16;
|
||||
for(Object o : list)
|
||||
{
|
||||
total += estimateBytes(o);
|
||||
}
|
||||
return (total);
|
||||
}
|
||||
if(value instanceof Map<?, ?>)
|
||||
{
|
||||
return (128);
|
||||
}
|
||||
return (16);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.sql.Statement;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registry of executions in flight: enforces the per-user concurrency limit
|
||||
** and lets the owner cancel a running statement.
|
||||
*******************************************************************************/
|
||||
public class RunningStatements
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(RunningStatements.class);
|
||||
|
||||
private final Map<String, Running> running = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One execution in flight.
|
||||
*******************************************************************************/
|
||||
public static class Running
|
||||
{
|
||||
private final String userId;
|
||||
private volatile Statement statement;
|
||||
private volatile boolean cancelled;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Running(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the owner asked to cancel.
|
||||
*******************************************************************************/
|
||||
public boolean isCancelled()
|
||||
{
|
||||
return (cancelled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The statement now running (the engine swaps it per statement). If a
|
||||
** cancel already arrived, the statement is cancelled at once.
|
||||
*******************************************************************************/
|
||||
public void setStatement(Statement statement)
|
||||
{
|
||||
this.statement = statement;
|
||||
if(cancelled)
|
||||
{
|
||||
cancelQuietly(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register an execution, unless the user already has maxPerUser running.
|
||||
** An id that is already registered is refused (never overwritten), so a
|
||||
** re-sent id cannot replace - and later unregister - the original run.
|
||||
**
|
||||
** @return the registration, or null when the user is at the limit
|
||||
** @throws DuplicateExecutionException when the id is already running
|
||||
*******************************************************************************/
|
||||
public synchronized Running register(String executionUuid, String userId, int maxPerUser) throws DuplicateExecutionException
|
||||
{
|
||||
if(running.containsKey(executionUuid))
|
||||
{
|
||||
throw (new DuplicateExecutionException(executionUuid));
|
||||
}
|
||||
long mine = running.values().stream().filter(r -> Objects.equals(r.userId, userId)).count();
|
||||
if(mine >= maxPerUser)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Running registration = new Running(userId);
|
||||
if(running.putIfAbsent(executionUuid, registration) != null)
|
||||
{
|
||||
throw (new DuplicateExecutionException(executionUuid));
|
||||
}
|
||||
return (registration);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove this registration - only if it is still the one under the id.
|
||||
*******************************************************************************/
|
||||
public void unregister(String executionUuid, Running registration)
|
||||
{
|
||||
running.remove(executionUuid, registration);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an execution with this id is in flight.
|
||||
*******************************************************************************/
|
||||
public boolean isRunning(String executionUuid)
|
||||
{
|
||||
return (executionUuid != null && running.containsKey(executionUuid));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel an execution, only for its owner.
|
||||
**
|
||||
** @return whether a running execution of this user was found
|
||||
*******************************************************************************/
|
||||
public boolean cancel(String executionUuid, String userId)
|
||||
{
|
||||
Running registration = running.get(executionUuid);
|
||||
if(registration == null || !Objects.equals(registration.userId, userId))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
registration.cancelled = true;
|
||||
cancelQuietly(registration.statement);
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public int size()
|
||||
{
|
||||
return (running.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void cancelQuietly(Statement statement)
|
||||
{
|
||||
if(statement == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
statement.cancel();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.info("Could not cancel a SQL Workbench statement", logPair("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.concurrent.Executors;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wraps one workbench session on one connection: puts it in a transaction
|
||||
** (read-only where the database supports it), applies the statement
|
||||
** timeout, and on close always ends the transaction (rollback unless
|
||||
** committed) and restores every connection property it changed, so the
|
||||
** connection goes back to the pool clean. If restoring fails, the
|
||||
** connection is aborted rather than returned dirty.
|
||||
**
|
||||
** All engine-side settings are transaction-scoped (SET LOCAL) or reverted
|
||||
** here; user SQL can never change session state (the classifier forbids
|
||||
** SET / RESET / USE / PRAGMA assignments / transaction control).
|
||||
*******************************************************************************/
|
||||
public class SessionGuard implements AutoCloseable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SessionGuard.class);
|
||||
|
||||
private final Connection connection;
|
||||
private final SqlDialect dialect;
|
||||
private final boolean readOnly;
|
||||
private final int timeoutSeconds;
|
||||
|
||||
private boolean originalAutoCommit = true;
|
||||
private boolean originalReadOnly = false;
|
||||
private boolean sqliteQueryOnly = false;
|
||||
private boolean committed = false;
|
||||
private boolean began = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SessionGuard(Connection connection, SqlDialect dialect, boolean readOnly, int timeoutSeconds)
|
||||
{
|
||||
this.connection = connection;
|
||||
this.dialect = dialect;
|
||||
this.readOnly = readOnly;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start the session's transaction.
|
||||
*******************************************************************************/
|
||||
public SessionGuard begin() throws SQLException
|
||||
{
|
||||
originalAutoCommit = connection.getAutoCommit();
|
||||
originalReadOnly = connection.isReadOnly();
|
||||
began = true;
|
||||
|
||||
if(readOnly && dialect == SqlDialect.SQLITE)
|
||||
{
|
||||
execute("PRAGMA query_only = 1");
|
||||
sqliteQueryOnly = true;
|
||||
}
|
||||
|
||||
if(readOnly && dialect != SqlDialect.SQLITE)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// before the transaction starts: pgjdbc then begins it READ ONLY, //
|
||||
// Connector/J sets the session's transaction characteristic //
|
||||
// (reverted in close), H2 treats it as a hint. //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
connection.setReadOnly(true);
|
||||
}
|
||||
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
if(dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
if(readOnly)
|
||||
{
|
||||
execute("SET TRANSACTION READ ONLY");
|
||||
}
|
||||
long millis = timeoutSeconds * 1000L;
|
||||
execute("SET LOCAL statement_timeout = " + millis);
|
||||
execute("SET LOCAL lock_timeout = " + millis);
|
||||
execute("SET LOCAL idle_in_transaction_session_timeout = " + (millis + 60_000L));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the session's limits to a statement.
|
||||
*******************************************************************************/
|
||||
public void configure(Statement statement, int maxRows, int fetchSize) throws SQLException
|
||||
{
|
||||
statement.setQueryTimeout(timeoutSeconds);
|
||||
if(maxRows > 0)
|
||||
{
|
||||
statement.setMaxRows(maxRows);
|
||||
}
|
||||
if(fetchSize > 0)
|
||||
{
|
||||
//////////////////////////////////////////////////////
|
||||
// some drivers (H2) refuse a fetch size > max rows //
|
||||
//////////////////////////////////////////////////////
|
||||
statement.setFetchSize(maxRows > 0 ? Math.min(fetchSize, maxRows) : fetchSize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit (confirmed writes only).
|
||||
*******************************************************************************/
|
||||
public void commit() throws SQLException
|
||||
{
|
||||
connection.commit();
|
||||
committed = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Roll back now (the guard still restores and closes later).
|
||||
*******************************************************************************/
|
||||
public void rollback() throws SQLException
|
||||
{
|
||||
connection.rollback();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Connection getConnection()
|
||||
{
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** End the transaction, restore the connection, and release it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
boolean clean = true;
|
||||
try
|
||||
{
|
||||
if(began && !committed)
|
||||
{
|
||||
connection.rollback();
|
||||
}
|
||||
if(sqliteQueryOnly)
|
||||
{
|
||||
connection.setAutoCommit(true);
|
||||
execute("PRAGMA query_only = 0");
|
||||
}
|
||||
if(began)
|
||||
{
|
||||
connection.setAutoCommit(originalAutoCommit);
|
||||
if(connection.isReadOnly() != originalReadOnly)
|
||||
{
|
||||
connection.setReadOnly(originalReadOnly);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
clean = false;
|
||||
LOG.warn("SQL Workbench could not restore a connection; aborting it", logPair("dialect", dialect), logPair("message", e.getMessage()));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if(clean)
|
||||
{
|
||||
connection.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
abort();
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("SQL Workbench could not release a connection", logPair("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Abort a connection that could not be restored, so a pool never reuses it.
|
||||
*******************************************************************************/
|
||||
private void abort() throws SQLException
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.abort(Executors.newVirtualThreadPerTaskExecutor());
|
||||
}
|
||||
catch(SQLException | RuntimeException | AbstractMethodError e)
|
||||
{
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void execute(String sql) throws SQLException
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,883 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.sql.Types;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.explain.PlanNode;
|
||||
import com.kingsrook.qbits.sqlworkbench.explain.PlanParser;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.NamedParameterSql;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlClassifier;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlStatement;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.StatementKind;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs workbench SQL: read-only sessions, the write-mode preview / commit
|
||||
** protocol, and EXPLAIN. Every run is validated, audited (RUNNING row
|
||||
** first; no audit, no run), guarded (SessionGuard), bounded (timeouts, row
|
||||
** and byte caps) and cancellable.
|
||||
**
|
||||
** SQL errors are results, not exceptions: the response has status FAILED /
|
||||
** TIMED_OUT / CANCELLED and the database's message. Exceptions are for the
|
||||
** caller's mistakes (QBadRequestException), permissions
|
||||
** (QPermissionDeniedException) and unknown targets (QNotFoundException).
|
||||
**
|
||||
** Logs never carry SQL text, parameters or results.
|
||||
*******************************************************************************/
|
||||
public class SqlExecutionEngine
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SqlExecutionEngine.class);
|
||||
|
||||
private static final java.util.regex.Pattern UUID_PATTERN = java.util.regex.Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
||||
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
private final WorkbenchBackends backends;
|
||||
private final ExecutionAuditor auditor;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlExecutionEngine(QInstance qInstance, SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.config = runtime.getConfig();
|
||||
this.backends = new WorkbenchBackends(qInstance, runtime);
|
||||
this.auditor = new ExecutionAuditor(config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The target resolver this engine uses.
|
||||
*******************************************************************************/
|
||||
public WorkbenchBackends getBackends()
|
||||
{
|
||||
return (backends);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run SQL: read-only, write preview, or write commit.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> execute(ExecutionRequest request, Caller caller) throws QException
|
||||
{
|
||||
if(request.executionUuid() != null)
|
||||
{
|
||||
if(!UUID_PATTERN.matcher(request.executionUuid()).matches())
|
||||
{
|
||||
throw (new QBadRequestException("executionUuid must be a UUID."));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// ids are single-use: a re-sent id must not reach the registry or //
|
||||
// the audit (UNIQUE) - it is refused before anything is recorded //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
String uuid = request.executionUuid().toLowerCase(Locale.ROOT);
|
||||
if(runtime.getRunningStatements().isRunning(uuid) || auditor.exists(uuid))
|
||||
{
|
||||
throw (new DuplicateExecutionException(uuid));
|
||||
}
|
||||
}
|
||||
|
||||
WorkbenchBackends.Target target = backends.getTarget(request.backendName());
|
||||
Map<String, Object> params = request.params() == null ? Map.of() : request.params();
|
||||
SqlWorkbenchExecution audit = newAudit(request, caller, params, request.write() ? ExecutionMode.WRITE_PREVIEW : ExecutionMode.READ_ONLY);
|
||||
List<SqlStatement> statements = validate(request.sql(), target.dialect(), params, audit);
|
||||
audit.setStatementCount(statements.size());
|
||||
audit.setStatementKinds(String.join(",", statements.stream().map(s -> s.kind().name()).distinct().toList()));
|
||||
|
||||
if(!request.write())
|
||||
{
|
||||
if(statements.size() != 1 || statements.get(0).kind() != StatementKind.READ)
|
||||
{
|
||||
reject(audit, "Read-only sessions run exactly one read-only statement (SELECT, WITH, VALUES, SHOW, EXPLAIN). "
|
||||
+ describeNonReads(statements) + "Use write mode for anything else.");
|
||||
}
|
||||
return (runReadOnly(target, statements.get(0), params, request, caller, audit));
|
||||
}
|
||||
|
||||
if(!caller.canWrite() || !Boolean.TRUE.equals(config.getWriteModeEnabled()))
|
||||
{
|
||||
rejectPermission(audit, "Write mode needs the " + config.getWritePermissionName() + " permission.");
|
||||
}
|
||||
if(statements.size() > config.getMaxStatementsPerRun())
|
||||
{
|
||||
reject(audit, "Too many statements (" + statements.size() + "); the limit is " + config.getMaxStatementsPerRun() + ".");
|
||||
}
|
||||
|
||||
if(!StringUtils.hasContent(request.confirmToken()))
|
||||
{
|
||||
return (runWritePreview(target, statements, params, caller, audit));
|
||||
}
|
||||
audit.setMode(ExecutionMode.WRITE_COMMIT.name());
|
||||
return (runWriteCommit(target, statements, params, request, caller, audit));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** EXPLAIN (or EXPLAIN ANALYZE) one statement.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> explain(String backendName, String sql, Map<String, Object> rawParams, boolean analyze, Caller caller) throws QException
|
||||
{
|
||||
WorkbenchBackends.Target target = backends.getTarget(backendName);
|
||||
Map<String, Object> params = rawParams == null ? Map.of() : rawParams;
|
||||
ExecutionRequest request = new ExecutionRequest(backendName, sql, params, false, null, null, null, null, false);
|
||||
SqlWorkbenchExecution audit = newAudit(request, caller, params, analyze ? ExecutionMode.EXPLAIN_ANALYZE : ExecutionMode.EXPLAIN);
|
||||
|
||||
List<SqlStatement> statements = validate(sql, target.dialect(), params, audit);
|
||||
audit.setStatementCount(statements.size());
|
||||
SqlStatement statement = statements.get(0);
|
||||
audit.setStatementKinds(statement.kind().name());
|
||||
|
||||
if(statements.size() != 1)
|
||||
{
|
||||
reject(audit, "EXPLAIN takes exactly one statement.");
|
||||
}
|
||||
if("EXPLAIN".equals(statement.leadingKeyword()))
|
||||
{
|
||||
reject(audit, "Give the statement to explain, without EXPLAIN.");
|
||||
}
|
||||
if(analyze && (!caller.canWrite() || !Boolean.TRUE.equals(config.getWriteModeEnabled())))
|
||||
{
|
||||
rejectPermission(audit, "EXPLAIN ANALYZE executes the statement, so it needs the " + config.getWritePermissionName() + " permission.");
|
||||
}
|
||||
if(statement.kind() != StatementKind.READ && !(analyze && statement.kind() == StatementKind.WRITE))
|
||||
{
|
||||
reject(audit, "Only read-only statements can be explained" + (analyze ? " (or DML with EXPLAIN ANALYZE)" : "") + ".");
|
||||
}
|
||||
if(analyze && !PlanParser.supportsAnalyze(target.dialect()))
|
||||
{
|
||||
reject(audit, "EXPLAIN ANALYZE is not supported on " + target.dialect().getUiName() + ".");
|
||||
}
|
||||
|
||||
boolean readOnly = statement.kind() == StatementKind.READ;
|
||||
int timeout = readOnly ? config.getStatementTimeoutSeconds() : config.getWriteStatementTimeoutSeconds();
|
||||
NamedParameterSql named = NamedParameterSql.of(statement);
|
||||
String explain = PlanParser.explainSql(target.dialect(), named.jdbcSql(), analyze);
|
||||
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, readOnly), target.dialect(), readOnly, timeout).begin())
|
||||
{
|
||||
CollectedResult result = runOne(guard, explain, named.parameterOrder(), params, target.dialect(), registration).result();
|
||||
guard.rollback();
|
||||
|
||||
List<List<Object>> rows = result == null ? List.of() : result.rows();
|
||||
PlanNode plan = PlanParser.parse(target.dialect(), rows, analyze);
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.SUCCEEDED, rows.size(), null, false, null));
|
||||
response.put("status", ExecutionStatus.SUCCEEDED.name());
|
||||
response.put("dialect", target.dialect().getUiName());
|
||||
response.put("analyze", analyze);
|
||||
response.put("plan", plan.toMap());
|
||||
response.put("raw", PlanParser.raw(rows));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
}
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel a running execution of this caller.
|
||||
*******************************************************************************/
|
||||
public boolean cancel(String executionUuid, Caller caller)
|
||||
{
|
||||
return (executionUuid != null && runtime.getRunningStatements().cancel(executionUuid.toLowerCase(Locale.ROOT), caller.userId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate length, split and classify, refuse FORBIDDEN, check params.
|
||||
*******************************************************************************/
|
||||
private List<SqlStatement> validate(String sql, SqlDialect dialect, Map<String, Object> params, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(sql))
|
||||
{
|
||||
throw (new QBadRequestException("There is no SQL to run."));
|
||||
}
|
||||
if(sql.length() > config.getMaxSqlLength())
|
||||
{
|
||||
reject(audit, "The SQL is longer than the limit of " + config.getMaxSqlLength() + " characters.");
|
||||
}
|
||||
|
||||
List<SqlStatement> statements = SqlClassifier.split(sql, dialect);
|
||||
if(statements.isEmpty())
|
||||
{
|
||||
throw (new QBadRequestException("There is no SQL to run (only comments)."));
|
||||
}
|
||||
|
||||
for(SqlStatement statement : statements)
|
||||
{
|
||||
if(statement.kind() == StatementKind.FORBIDDEN)
|
||||
{
|
||||
reject(audit, "Not allowed: " + statement.reason() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> missing = new LinkedHashSet<>();
|
||||
for(SqlStatement statement : statements)
|
||||
{
|
||||
for(String name : statement.parameterNames())
|
||||
{
|
||||
if(!params.containsKey(name))
|
||||
{
|
||||
missing.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!missing.isEmpty())
|
||||
{
|
||||
reject(audit, "Missing value for parameter(s): " + String.join(", ", missing.stream().map(n -> ":" + n).toList()) + ".");
|
||||
}
|
||||
return (statements);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String describeNonReads(List<SqlStatement> statements)
|
||||
{
|
||||
if(statements.size() > 1)
|
||||
{
|
||||
return ("This has " + statements.size() + " statements. ");
|
||||
}
|
||||
SqlStatement statement = statements.get(0);
|
||||
return ("This statement is " + statement.kind().name().toLowerCase() + " (" + statement.reason() + "). ");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One read-only statement.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> runReadOnly(WorkbenchBackends.Target target, SqlStatement statement, Map<String, Object> params, ExecutionRequest request, Caller caller, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
NamedParameterSql named = NamedParameterSql.of(statement);
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, true), target.dialect(), true, config.getStatementTimeoutSeconds()).begin())
|
||||
{
|
||||
StatementOutcome outcome = runOne(guard, named.jdbcSql(), named.parameterOrder(), params, target.dialect(), registration);
|
||||
guard.rollback();
|
||||
putResult(response, audit, caller, target, outcome.result(), request.pageSize());
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.SUCCEEDED, outcome.result() == null ? 0 : outcome.result().rows().size(), null,
|
||||
outcome.result() != null && outcome.result().truncated(), null));
|
||||
response.put("status", ExecutionStatus.SUCCEEDED.name());
|
||||
response.put("statements", List.of(statementView(0, statement, null)));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
}
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write preview: dry run (where possible) in a transaction that is always
|
||||
** rolled back, then a confirmation token.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> runWritePreview(WorkbenchBackends.Target target, List<SqlStatement> statements, Map<String, Object> params, Caller caller, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
boolean anyDdl = statements.stream().anyMatch(s -> s.kind() == StatementKind.DDL);
|
||||
boolean dryRunSupported = !anyDdl || target.dialect().isTransactionalDdl();
|
||||
String sqlHash = audit.getSqlHash();
|
||||
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
List<Map<String, Object>> views = new ArrayList<>();
|
||||
Long total = null;
|
||||
|
||||
if(dryRunSupported)
|
||||
{
|
||||
total = 0L;
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, false), target.dialect(), false, config.getWriteStatementTimeoutSeconds()).begin())
|
||||
{
|
||||
for(int i = 0; i < statements.size(); i++)
|
||||
{
|
||||
NamedParameterSql named = NamedParameterSql.of(statements.get(i));
|
||||
StatementOutcome outcome = runOne(guard, named.jdbcSql(), named.parameterOrder(), params, target.dialect(), registration);
|
||||
long affected = outcome.affectedRows();
|
||||
total += affected;
|
||||
views.add(statementView(i, statements.get(i), affected));
|
||||
}
|
||||
guard.rollback();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
return (response);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for(int i = 0; i < statements.size(); i++)
|
||||
{
|
||||
views.add(statementView(i, statements.get(i), null));
|
||||
}
|
||||
}
|
||||
|
||||
boolean rowGuardExceeded = total != null && total > config.getMaxAffectedRowsWithoutAcknowledgement();
|
||||
String token = runtime.getWritePreviewTokens().issue(caller.userId(), target.name(), sqlHash, total, rowGuardExceeded);
|
||||
|
||||
finish(audit, dryRunSupported ? ExecutionStatus.ROLLED_BACK : ExecutionStatus.SUCCEEDED, null, total, false, null);
|
||||
response.put("status", "PREVIEW");
|
||||
response.put("statements", views);
|
||||
response.put("dryRunSupported", dryRunSupported);
|
||||
response.put("dryRunNote", dryRunSupported ? null : "DDL on " + target.dialect().getUiName() + " commits implicitly, so it cannot be dry-run.");
|
||||
response.put("totalAffectedRows", total);
|
||||
response.put("rowGuardExceeded", rowGuardExceeded);
|
||||
response.put("rowGuardLimit", config.getMaxAffectedRowsWithoutAcknowledgement());
|
||||
response.put("confirmToken", token);
|
||||
response.put("expiresAt", String.valueOf(runtime.getWritePreviewTokens().expiresAt(token)));
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write commit: check the token, re-run in one transaction, compare with
|
||||
** the preview, enforce the hard cap, commit.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> runWriteCommit(WorkbenchBackends.Target target, List<SqlStatement> statements, Map<String, Object> params, ExecutionRequest request, Caller caller, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
WritePreviewTokens tokens = runtime.getWritePreviewTokens();
|
||||
WritePreviewTokens.Preview preview = tokens.find(request.confirmToken(), caller.userId(), target.name(), audit.getSqlHash());
|
||||
if(preview == null)
|
||||
{
|
||||
reject(audit, "The write confirmation is missing, expired, or does not match this SQL; preview it again.");
|
||||
}
|
||||
if(preview.rowGuardExceeded() && !request.acknowledgeRowGuard())
|
||||
{
|
||||
reject(audit, "This write affects more than " + config.getMaxAffectedRowsWithoutAcknowledgement() + " rows; acknowledge the row guard to commit.");
|
||||
}
|
||||
if(!tokens.consume(request.confirmToken()))
|
||||
{
|
||||
reject(audit, "This write confirmation was already used; preview it again.");
|
||||
}
|
||||
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
List<Map<String, Object>> views = new ArrayList<>();
|
||||
long total = 0;
|
||||
CollectedResult last = null;
|
||||
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, false), target.dialect(), false, config.getWriteStatementTimeoutSeconds()).begin())
|
||||
{
|
||||
for(int i = 0; i < statements.size(); i++)
|
||||
{
|
||||
NamedParameterSql named = NamedParameterSql.of(statements.get(i));
|
||||
StatementOutcome outcome = runOne(guard, named.jdbcSql(), named.parameterOrder(), params, target.dialect(), registration);
|
||||
total += outcome.affectedRows();
|
||||
if(outcome.result() != null)
|
||||
{
|
||||
last = outcome.result();
|
||||
}
|
||||
views.add(statementView(i, statements.get(i), outcome.affectedRows()));
|
||||
}
|
||||
|
||||
long finalTotal = total;
|
||||
if(preview.affectedRows() != null && preview.affectedRows() != total)
|
||||
{
|
||||
guard.rollback();
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.DRIFTED, null, finalTotal, false, "Affected rows changed since the preview (" + preview.affectedRows() + " -> " + finalTotal + ")"));
|
||||
response.put("status", ExecutionStatus.DRIFTED.name());
|
||||
response.put("previewAffectedRows", preview.affectedRows());
|
||||
response.put("actualAffectedRows", total);
|
||||
response.put("statements", views);
|
||||
return (response);
|
||||
}
|
||||
|
||||
if(total > config.getMaxAffectedRows())
|
||||
{
|
||||
guard.rollback();
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.REJECTED, null, finalTotal, false, "Affected rows exceed the hard limit of " + config.getMaxAffectedRows()));
|
||||
response.put("status", ExecutionStatus.REJECTED.name());
|
||||
response.put("error", "This write affects " + total + " rows, more than the limit of " + config.getMaxAffectedRows() + "; it was rolled back.");
|
||||
response.put("statements", views);
|
||||
return (response);
|
||||
}
|
||||
|
||||
guard.commit();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
return (response);
|
||||
}
|
||||
|
||||
putResult(response, audit, caller, target, last, request.pageSize());
|
||||
finish(audit, ExecutionStatus.SUCCEEDED, last == null ? null : last.rows().size(), total, last != null && last.truncated(), null);
|
||||
response.put("status", ExecutionStatus.SUCCEEDED.name());
|
||||
response.put("statements", views);
|
||||
response.put("rowsAffected", total);
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of one statement.
|
||||
**
|
||||
** @param result its result set, if it returned one
|
||||
** @param updateCount its update count (-1 when it returned rows)
|
||||
*******************************************************************************/
|
||||
record StatementOutcome(CollectedResult result, long updateCount)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows this statement changed (for result-returning statements, e.g.
|
||||
** INSERT ... RETURNING, the rows returned).
|
||||
*******************************************************************************/
|
||||
long affectedRows()
|
||||
{
|
||||
if(updateCount >= 0)
|
||||
{
|
||||
return (updateCount);
|
||||
}
|
||||
return (result == null ? 0 : result.rows().size());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prepare, bind, run and collect one statement.
|
||||
*******************************************************************************/
|
||||
private StatementOutcome runOne(SessionGuard guard, String jdbcSql, List<String> parameterOrder, Map<String, Object> params, SqlDialect dialect, RunningStatements.Running registration) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = guard.getConnection().prepareStatement(jdbcSql))
|
||||
{
|
||||
guard.configure(statement, config.getMaxRows() + 1, config.getFetchSize());
|
||||
for(int i = 0; i < parameterOrder.size(); i++)
|
||||
{
|
||||
bind(statement, i + 1, params.get(parameterOrder.get(i)), dialect);
|
||||
}
|
||||
|
||||
registration.setStatement(statement);
|
||||
try
|
||||
{
|
||||
boolean hasResultSet = statement.execute();
|
||||
if(hasResultSet)
|
||||
{
|
||||
try(ResultSet resultSet = statement.getResultSet())
|
||||
{
|
||||
ResultCollector collector = new ResultCollector(config.getMaxRows(), config.getMaxResultBytes(), config.getMaxCellChars());
|
||||
return (new StatementOutcome(collector.collect(resultSet), -1));
|
||||
}
|
||||
}
|
||||
return (new StatementOutcome(null, Math.max(0, statement.getUpdateCount())));
|
||||
}
|
||||
finally
|
||||
{
|
||||
registration.setStatement(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind one parameter value. Strings on Postgres are bound untyped
|
||||
** (Types.OTHER) so the server infers their type from context (dates,
|
||||
** numbers, uuids); everywhere else they are strings, which the other
|
||||
** databases coerce.
|
||||
*******************************************************************************/
|
||||
static void bind(PreparedStatement statement, int index, Object value, SqlDialect dialect) throws SQLException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
statement.setNull(index, dialect == SqlDialect.POSTGRES ? Types.OTHER : Types.NULL);
|
||||
}
|
||||
else if(value instanceof Boolean b)
|
||||
{
|
||||
statement.setBoolean(index, b);
|
||||
}
|
||||
else if(value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte || value instanceof BigInteger)
|
||||
{
|
||||
statement.setLong(index, ((Number) value).longValue());
|
||||
}
|
||||
else if(value instanceof BigDecimal bd)
|
||||
{
|
||||
statement.setBigDecimal(index, bd);
|
||||
}
|
||||
else if(value instanceof Number n)
|
||||
{
|
||||
statement.setBigDecimal(index, new BigDecimal(n.toString()));
|
||||
}
|
||||
else if(dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
statement.setObject(index, value.toString(), Types.OTHER);
|
||||
}
|
||||
else
|
||||
{
|
||||
statement.setString(index, value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cache the result and put its first page in the response.
|
||||
*******************************************************************************/
|
||||
private void putResult(Map<String, Object> response, SqlWorkbenchExecution audit, Caller caller, WorkbenchBackends.Target target, CollectedResult result, Integer pageSize)
|
||||
{
|
||||
if(result == null)
|
||||
{
|
||||
response.put("columns", List.of());
|
||||
response.put("rows", List.of());
|
||||
response.put("rowCount", 0);
|
||||
response.put("hasMore", false);
|
||||
response.put("truncated", false);
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.getResultCache().put(audit.getExecutionUuid(), caller.userId(), target.name(), result);
|
||||
int size = pageSize == null || pageSize <= 0 ? config.getDefaultPageSize() : Math.min(pageSize, config.getMaxPageSize());
|
||||
response.put("columns", result.columns().stream().map(ColumnInfo::toMap).toList());
|
||||
response.put("rows", ResultCache.page(result, 0, size, null, false));
|
||||
response.put("rowCount", result.rows().size());
|
||||
response.put("hasMore", result.rows().size() > size);
|
||||
response.put("truncated", result.truncated());
|
||||
response.put("truncatedReason", result.truncatedReason());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> statementView(int index, SqlStatement statement, Long affectedRows)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("index", index);
|
||||
view.put("kind", statement.kind().name());
|
||||
view.put("leadingKeyword", statement.leadingKeyword());
|
||||
view.put("text", statement.text().length() > 500 ? statement.text().substring(0, 500) + "…" : statement.text());
|
||||
view.put("affectedRows", affectedRows);
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> baseResponse(SqlWorkbenchExecution audit)
|
||||
{
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("executionUuid", audit.getExecutionUuid());
|
||||
response.put("mode", audit.getMode());
|
||||
response.put("backendName", audit.getBackendName());
|
||||
return (response);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record a SQL failure in the audit and the response.
|
||||
*******************************************************************************/
|
||||
private void sqlFailure(SqlWorkbenchExecution audit, Map<String, Object> response, SQLException e, RunningStatements.Running registration) throws QException
|
||||
{
|
||||
ExecutionStatus status;
|
||||
if(registration.isCancelled())
|
||||
{
|
||||
status = ExecutionStatus.CANCELLED;
|
||||
}
|
||||
else if(e instanceof SQLTimeoutException || "57014".equals(e.getSQLState()))
|
||||
{
|
||||
status = ExecutionStatus.TIMED_OUT;
|
||||
}
|
||||
else
|
||||
{
|
||||
status = ExecutionStatus.FAILED;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// a failure to connect is not the caller's SQL: its driver message //
|
||||
// can expose hosts / users / URLs, so it stays in the server log //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
String message = e.getMessage();
|
||||
if(e instanceof WorkbenchBackends.ConnectionFailedException)
|
||||
{
|
||||
LOG.warn("SQL Workbench could not open a connection", e.getCause(), logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()));
|
||||
}
|
||||
|
||||
finish(audit, status, null, null, false, message);
|
||||
response.put("status", status.name());
|
||||
response.put("error", message);
|
||||
response.put("sqlState", e.getSQLState());
|
||||
LOG.info("SQL Workbench execution did not succeed", logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()),
|
||||
logPair("mode", audit.getMode()), logPair("status", status), logPair("sqlState", e.getSQLState()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register (concurrency limit), audit-start, run, and always unregister.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> withRegistration(SqlWorkbenchExecution audit, Caller caller, RegisteredRun run) throws QException
|
||||
{
|
||||
RunningStatements.Running registration = runtime.getRunningStatements().register(audit.getExecutionUuid(), caller.userId(), config.getMaxConcurrentExecutionsPerUser());
|
||||
if(registration == null)
|
||||
{
|
||||
reject(audit, "You already have " + config.getMaxConcurrentExecutionsPerUser() + " executions running; wait for one to finish or cancel it.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
auditor.start(audit);
|
||||
Deferred deferred = new Deferred();
|
||||
Map<String, Object> response;
|
||||
try
|
||||
{
|
||||
response = run.run(registration, deferred);
|
||||
deferred.run();
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
failUnfinished(audit, e);
|
||||
throw (e);
|
||||
}
|
||||
LOG.info("SQL Workbench execution", logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()),
|
||||
logPair("mode", audit.getMode()), logPair("status", audit.getStatus()), logPair("durationMs", audit.getDurationMs()));
|
||||
response.put("durationMs", audit.getDurationMs());
|
||||
return (response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.getRunningStatements().unregister(audit.getExecutionUuid(), registration);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unexpected exception escaped a started run: if its audit row is still
|
||||
** RUNNING, finish it as FAILED (with a generic message; the detail goes to
|
||||
** the server log). A failure to do so is logged, never masks the original.
|
||||
*******************************************************************************/
|
||||
private void failUnfinished(SqlWorkbenchExecution audit, Exception e)
|
||||
{
|
||||
LOG.warn("SQL Workbench execution failed unexpectedly", e, logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()));
|
||||
if(audit.getId() == null || !ExecutionStatus.RUNNING.name().equals(audit.getStatus()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
finish(audit, ExecutionStatus.FAILED, null, null, false, "The execution failed unexpectedly; see the server log.");
|
||||
}
|
||||
catch(Exception finishFailure)
|
||||
{
|
||||
LOG.warn("Could not finish the SQL Workbench audit record", finishFailure, logPair("executionUuid", audit.getExecutionUuid()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run with its registration, and a place to defer the audit update until
|
||||
** the session's connection is released (so an audit write never needs a
|
||||
** second connection from a pool the session is holding).
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface RegisteredRun
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Map<String, Object> run(RunningStatements.Running registration, Deferred deferred) throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One deferred audit update.
|
||||
*******************************************************************************/
|
||||
private static class Deferred
|
||||
{
|
||||
private SystemUser.Block action;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void set(SystemUser.Block action)
|
||||
{
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run() throws QException
|
||||
{
|
||||
if(action != null)
|
||||
{
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void finish(SqlWorkbenchExecution audit, ExecutionStatus status, Integer rowsReturned, Long rowsAffected, Boolean truncated, String error) throws QException
|
||||
{
|
||||
auditor.finish(audit, status, runtime.getClock().instant(), rowsReturned, rowsAffected, truncated, error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit a rejected request, then throw QBadRequestException.
|
||||
*******************************************************************************/
|
||||
private void reject(SqlWorkbenchExecution audit, String message) throws QException
|
||||
{
|
||||
recordRejection(audit, message);
|
||||
throw (new QBadRequestException(message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit a permission refusal, then throw QPermissionDeniedException.
|
||||
*******************************************************************************/
|
||||
private void rejectPermission(SqlWorkbenchExecution audit, String message) throws QException
|
||||
{
|
||||
recordRejection(audit, message);
|
||||
throw (new QPermissionDeniedException(message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void recordRejection(SqlWorkbenchExecution audit, String message) throws QException
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
audit.setStatus(ExecutionStatus.REJECTED.name());
|
||||
audit.setEndTime(now);
|
||||
audit.setDurationMs(0);
|
||||
audit.setErrorMessage(message);
|
||||
auditor.record(audit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A new audit record for a request.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchExecution newAudit(ExecutionRequest request, Caller caller, Map<String, Object> params, ExecutionMode mode)
|
||||
{
|
||||
return (new SqlWorkbenchExecution()
|
||||
.withExecutionUuid(request.executionUuid() != null ? request.executionUuid().toLowerCase(Locale.ROOT) : UUID.randomUUID().toString())
|
||||
.withUserId(caller.userId())
|
||||
.withUserName(caller.userName())
|
||||
.withBackendName(request.backendName())
|
||||
.withMode(mode.name())
|
||||
.withSqlText(request.sql())
|
||||
.withSqlHash(sqlHash(request.backendName(), request.sql(), params))
|
||||
.withParametersJson(params.isEmpty() ? null : JsonUtils.toJson(new TreeMap<>(params)))
|
||||
.withNotebookId(request.notebookId())
|
||||
.withCellKey(request.cellKey())
|
||||
.withClientIp(caller.clientIp())
|
||||
.withStartTime(runtime.getClock().instant()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of backend, SQL and parameters: binds a write confirmation to
|
||||
** exactly what was previewed.
|
||||
*******************************************************************************/
|
||||
static String sqlHash(String backendName, String sql, Map<String, Object> params)
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
String input = backendName + "\u0000" + sql + "\u0000" + JsonUtils.toJson(new TreeMap<>(params == null ? Map.of() : params));
|
||||
return (HexFormat.of().formatHex(digest.digest(input.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchCredentials;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves the workbench's target backends (the instance's RDBMS backends,
|
||||
** filtered by the config's allow / deny lists) and opens connections to
|
||||
** them through QQQ's ConnectionManager.
|
||||
**
|
||||
** Connection source, read-only sessions: readOnlyCredentials for the backend
|
||||
** (a derived backend meta-data with its own pool), else the backend's
|
||||
** readOnlyBackendMetaData when useBackendReadOnlyReplica, else the backend.
|
||||
** Write sessions: writeCredentials for the backend, else the backend.
|
||||
*******************************************************************************/
|
||||
public class WorkbenchBackends
|
||||
{
|
||||
public static final String READ_ONLY_SUFFIX = "__sqlWorkbenchReadOnly";
|
||||
public static final String WRITE_SUFFIX = "__sqlWorkbenchWrite";
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A target backend.
|
||||
**
|
||||
** @param backend the instance's backend meta-data
|
||||
** @param dialect its dialect
|
||||
*******************************************************************************/
|
||||
public record Target(RDBMSBackendMetaData backend, SqlDialect dialect)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend's name.
|
||||
*******************************************************************************/
|
||||
public String name()
|
||||
{
|
||||
return (backend.getName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WorkbenchBackends(QInstance qInstance, SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every target backend, in instance order.
|
||||
*******************************************************************************/
|
||||
public List<Target> listTargets()
|
||||
{
|
||||
List<Target> targets = new ArrayList<>();
|
||||
for(QBackendMetaData backend : qInstance.getBackends().values())
|
||||
{
|
||||
if(backend instanceof RDBMSBackendMetaData rdbms && isAllowed(rdbms.getName()))
|
||||
{
|
||||
targets.add(new Target(rdbms, SqlDialect.fromVendor(rdbms.getVendor())));
|
||||
}
|
||||
}
|
||||
return (targets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One target, or QNotFoundException for an unknown, non-RDBMS or excluded
|
||||
** backend.
|
||||
*******************************************************************************/
|
||||
public Target getTarget(String backendName) throws QNotFoundException
|
||||
{
|
||||
QBackendMetaData backend = StringUtils.hasContent(backendName) ? qInstance.getBackend(backendName) : null;
|
||||
if(backend instanceof RDBMSBackendMetaData rdbms && isAllowed(backendName))
|
||||
{
|
||||
return (new Target(rdbms, SqlDialect.fromVendor(rdbms.getVendor())));
|
||||
}
|
||||
throw (new QNotFoundException("Unknown SQL Workbench backend: " + backendName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private boolean isAllowed(String backendName)
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = runtime.getConfig();
|
||||
if(backendName == null || backendName.endsWith(READ_ONLY_SUFFIX) || backendName.endsWith(WRITE_SUFFIX))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(config.getExcludedBackendNames() != null && config.getExcludedBackendNames().contains(backendName))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (config.getAllowedBackendNames() == null || config.getAllowedBackendNames().contains(backendName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether read-only sessions on this target use dedicated credentials.
|
||||
*******************************************************************************/
|
||||
public boolean hasReadOnlyCredentials(Target target)
|
||||
{
|
||||
Map<String, SqlWorkbenchCredentials> credentials = runtime.getConfig().getReadOnlyCredentials();
|
||||
return (credentials != null && credentials.containsKey(target.name()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend meta-data a session connects with.
|
||||
*******************************************************************************/
|
||||
public RDBMSBackendMetaData connectionSource(Target target, boolean readOnly)
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = runtime.getConfig();
|
||||
if(readOnly)
|
||||
{
|
||||
SqlWorkbenchCredentials credentials = config.getReadOnlyCredentials() == null ? null : config.getReadOnlyCredentials().get(target.name());
|
||||
if(credentials != null)
|
||||
{
|
||||
return (derive(target.backend(), credentials, READ_ONLY_SUFFIX));
|
||||
}
|
||||
if(Boolean.TRUE.equals(config.getUseBackendReadOnlyReplica()) && target.backend().getReadOnlyBackendMetaData() != null)
|
||||
{
|
||||
return (target.backend().getReadOnlyBackendMetaData());
|
||||
}
|
||||
return (target.backend());
|
||||
}
|
||||
|
||||
SqlWorkbenchCredentials credentials = config.getWriteCredentials() == null ? null : config.getWriteCredentials().get(target.name());
|
||||
return (credentials == null ? target.backend() : derive(target.backend(), credentials, WRITE_SUFFIX));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a connection for a session.
|
||||
*******************************************************************************/
|
||||
public Connection openConnection(Target target, boolean readOnly) throws SQLException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (ConnectionManager.getConnection(connectionSource(target, readOnly)));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (new ConnectionFailedException(target.name(), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The connection to a target could not be opened. Its driver message may
|
||||
** carry hosts, user names or URL details, so callers show a generic
|
||||
** message (getMessage) and log the cause server-side.
|
||||
*******************************************************************************/
|
||||
public static class ConnectionFailedException extends SQLException
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ConnectionFailedException(String backendName, SQLException cause)
|
||||
{
|
||||
super("Could not connect to backend " + backendName + "; see the server log.", (String) null, cause);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A copy of a backend's connection settings with other credentials, under a
|
||||
** derived name (so it gets its own connection provider / pool). Built once
|
||||
** per backend and suffix.
|
||||
*******************************************************************************/
|
||||
RDBMSBackendMetaData derive(RDBMSBackendMetaData original, SqlWorkbenchCredentials credentials, String suffix)
|
||||
{
|
||||
String name = original.getName() + suffix;
|
||||
return (runtime.getDerivedBackends().computeIfAbsent(name, n ->
|
||||
{
|
||||
QMetaDataVariableInterpreter interpreter = new QMetaDataVariableInterpreter();
|
||||
RDBMSBackendMetaData derived = new RDBMSBackendMetaData();
|
||||
derived.setName(n);
|
||||
derived.setVendor(original.getVendor());
|
||||
derived.setHostName(original.getHostName());
|
||||
derived.setPort(original.getPort());
|
||||
derived.setDatabaseName(original.getDatabaseName());
|
||||
derived.setJdbcUrl(StringUtils.hasContent(credentials.getJdbcUrl()) ? interpreter.interpret(credentials.getJdbcUrl()) : ConnectionManager.getJdbcUrl(original));
|
||||
derived.setJdbcDriverClassName(ConnectionManager.getJdbcDriverClassName(original));
|
||||
derived.setConnectionProvider(original.getConnectionProvider());
|
||||
derived.setConnectionPoolSettings(original.getConnectionPoolSettings());
|
||||
derived.setQueriesForNewConnections(original.getQueriesForNewConnections());
|
||||
derived.setUsername(interpreter.interpret(credentials.getUsername()));
|
||||
derived.setPassword(interpreter.interpret(credentials.getPassword()));
|
||||
return (derived);
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Single-use confirmation tokens for write mode. A preview issues a token
|
||||
** bound to the user, backend and SQL hash (SQL plus parameters); a commit
|
||||
** must present it before it expires. Tokens live only in memory, so a
|
||||
** restart forces a new preview.
|
||||
*******************************************************************************/
|
||||
public class WritePreviewTokens
|
||||
{
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final Map<String, Preview> previews = new ConcurrentHashMap<>();
|
||||
private final long ttlMillis;
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a preview found.
|
||||
**
|
||||
** @param userId who previewed
|
||||
** @param backendName where
|
||||
** @param sqlHash hash of SQL plus parameters
|
||||
** @param affectedRows total rows the dry run affected (null when no
|
||||
** dry run was possible)
|
||||
** @param rowGuardExceeded whether affectedRows exceeded the guard
|
||||
** @param expiresAt when the token stops working
|
||||
*******************************************************************************/
|
||||
public record Preview(String userId, String backendName, String sqlHash, Long affectedRows, boolean rowGuardExceeded, Instant expiresAt)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WritePreviewTokens(int ttlSeconds, Clock clock)
|
||||
{
|
||||
this.ttlMillis = ttlSeconds * 1000L;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Issue a token for a preview.
|
||||
*******************************************************************************/
|
||||
public String issue(String userId, String backendName, String sqlHash, Long affectedRows, boolean rowGuardExceeded)
|
||||
{
|
||||
purgeExpired();
|
||||
byte[] bytes = new byte[24];
|
||||
RANDOM.nextBytes(bytes);
|
||||
String token = HexFormat.of().formatHex(bytes);
|
||||
previews.put(token, new Preview(userId, backendName, sqlHash, affectedRows, rowGuardExceeded, clock.instant().plusMillis(ttlMillis)));
|
||||
return (token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** When the token expires (for responses), or null for an unknown token.
|
||||
*******************************************************************************/
|
||||
public Instant expiresAt(String token)
|
||||
{
|
||||
Preview preview = previews.get(token);
|
||||
return (preview == null ? null : preview.expiresAt());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up a token without consuming it.
|
||||
**
|
||||
** @return the preview, or null when the token is unknown, expired, or bound
|
||||
** to a different user, backend or SQL
|
||||
*******************************************************************************/
|
||||
public Preview find(String token, String userId, String backendName, String sqlHash)
|
||||
{
|
||||
if(token == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Preview preview = previews.get(token);
|
||||
if(preview == null || preview.expiresAt().isBefore(clock.instant()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(!Objects.equals(preview.userId(), userId) || !Objects.equals(preview.backendName(), backendName) || !Objects.equals(preview.sqlHash(), sqlHash))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (preview);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Consume a token: it can never be used again.
|
||||
**
|
||||
** @return whether this call removed it (false if another commit won)
|
||||
*******************************************************************************/
|
||||
public boolean consume(String token)
|
||||
{
|
||||
return (token != null && previews.remove(token) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void purgeExpired()
|
||||
{
|
||||
Instant now = clock.instant();
|
||||
previews.values().removeIf(p -> p.expiresAt().isBefore(now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public int size()
|
||||
{
|
||||
purgeExpired();
|
||||
return (previews.size());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.explain;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One node of a normalized query plan. Numeric fields are null when the
|
||||
** database does not report them (e.g. actual* without ANALYZE).
|
||||
*******************************************************************************/
|
||||
public class PlanNode
|
||||
{
|
||||
private String label;
|
||||
private String detail;
|
||||
private Double startupCost;
|
||||
private Double totalCost;
|
||||
private Double planRows;
|
||||
private Double actualTimeMs;
|
||||
private Double actualRows;
|
||||
private Double loops;
|
||||
private Map<String, Object> extra = new LinkedHashMap<>();
|
||||
private List<PlanNode> children = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public PlanNode()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public PlanNode(String label, String detail)
|
||||
{
|
||||
this.label = label;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON view of this node and its children.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> toMap()
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", label);
|
||||
map.put("detail", detail);
|
||||
map.put("startupCost", startupCost);
|
||||
map.put("totalCost", totalCost);
|
||||
map.put("planRows", planRows);
|
||||
map.put("actualTimeMs", actualTimeMs);
|
||||
map.put("actualRows", actualRows);
|
||||
map.put("loops", loops);
|
||||
map.put("extra", extra);
|
||||
map.put("children", children.stream().map(PlanNode::toMap).toList());
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent child adder.
|
||||
*******************************************************************************/
|
||||
public PlanNode withChild(PlanNode child)
|
||||
{
|
||||
children.add(child);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for detail
|
||||
*******************************************************************************/
|
||||
public String getDetail()
|
||||
{
|
||||
return (detail);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for detail
|
||||
*******************************************************************************/
|
||||
public void setDetail(String detail)
|
||||
{
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startupCost
|
||||
*******************************************************************************/
|
||||
public Double getStartupCost()
|
||||
{
|
||||
return (startupCost);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for startupCost
|
||||
*******************************************************************************/
|
||||
public void setStartupCost(Double startupCost)
|
||||
{
|
||||
this.startupCost = startupCost;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for totalCost
|
||||
*******************************************************************************/
|
||||
public Double getTotalCost()
|
||||
{
|
||||
return (totalCost);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for totalCost
|
||||
*******************************************************************************/
|
||||
public void setTotalCost(Double totalCost)
|
||||
{
|
||||
this.totalCost = totalCost;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for planRows
|
||||
*******************************************************************************/
|
||||
public Double getPlanRows()
|
||||
{
|
||||
return (planRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for planRows
|
||||
*******************************************************************************/
|
||||
public void setPlanRows(Double planRows)
|
||||
{
|
||||
this.planRows = planRows;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for actualTimeMs
|
||||
*******************************************************************************/
|
||||
public Double getActualTimeMs()
|
||||
{
|
||||
return (actualTimeMs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for actualTimeMs
|
||||
*******************************************************************************/
|
||||
public void setActualTimeMs(Double actualTimeMs)
|
||||
{
|
||||
this.actualTimeMs = actualTimeMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for actualRows
|
||||
*******************************************************************************/
|
||||
public Double getActualRows()
|
||||
{
|
||||
return (actualRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for actualRows
|
||||
*******************************************************************************/
|
||||
public void setActualRows(Double actualRows)
|
||||
{
|
||||
this.actualRows = actualRows;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for loops
|
||||
*******************************************************************************/
|
||||
public Double getLoops()
|
||||
{
|
||||
return (loops);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for loops
|
||||
*******************************************************************************/
|
||||
public void setLoops(Double loops)
|
||||
{
|
||||
this.loops = loops;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for extra
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> getExtra()
|
||||
{
|
||||
return (extra);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for children
|
||||
*******************************************************************************/
|
||||
public List<PlanNode> getChildren()
|
||||
{
|
||||
return (children);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.explain;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds EXPLAIN commands per dialect and normalizes their output into a
|
||||
** PlanNode tree.
|
||||
**
|
||||
** Postgres: EXPLAIN (FORMAT JSON[, ANALYZE, BUFFERS]) - the JSON "Plan" tree.
|
||||
** MySQL: EXPLAIN FORMAT=JSON (query_block, best effort) or EXPLAIN
|
||||
** ANALYZE (text tree).
|
||||
** SQLite: EXPLAIN QUERY PLAN - (id, parent, notused, detail) rows.
|
||||
** H2 / other: EXPLAIN [ANALYZE] - text, as one node per line.
|
||||
*******************************************************************************/
|
||||
public class PlanParser
|
||||
{
|
||||
private static final Set<String> POSTGRES_KNOWN_KEYS = Set.of("Node Type", "Plans", "Startup Cost", "Total Cost", "Plan Rows", "Actual Total Time",
|
||||
"Actual Rows", "Actual Loops", "Relation Name", "Alias", "Index Name", "Join Type", "Strategy", "Parent Relationship");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The EXPLAIN command for a statement.
|
||||
*******************************************************************************/
|
||||
public static String explainSql(SqlDialect dialect, String sql, boolean analyze)
|
||||
{
|
||||
return switch(dialect)
|
||||
{
|
||||
case POSTGRES -> analyze ? "EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) " + sql : "EXPLAIN (FORMAT JSON) " + sql;
|
||||
case MYSQL -> analyze ? "EXPLAIN ANALYZE " + sql : "EXPLAIN FORMAT=JSON " + sql;
|
||||
case SQLITE -> "EXPLAIN QUERY PLAN " + sql;
|
||||
default -> analyze ? "EXPLAIN ANALYZE " + sql : "EXPLAIN " + sql;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this dialect can EXPLAIN ANALYZE.
|
||||
*******************************************************************************/
|
||||
public static boolean supportsAnalyze(SqlDialect dialect)
|
||||
{
|
||||
return (dialect != SqlDialect.SQLITE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the EXPLAIN result rows into a tree.
|
||||
**
|
||||
** @param rows the EXPLAIN output (converted values, one list per row)
|
||||
*******************************************************************************/
|
||||
public static PlanNode parse(SqlDialect dialect, List<List<Object>> rows, boolean analyze)
|
||||
{
|
||||
if(rows == null || rows.isEmpty())
|
||||
{
|
||||
return (new PlanNode("(empty plan)", null));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return switch(dialect)
|
||||
{
|
||||
case POSTGRES -> parsePostgres(firstCell(rows));
|
||||
case MYSQL -> analyze ? parseText(rows) : parseMySqlJson(firstCell(rows));
|
||||
case SQLITE -> parseSqlite(rows);
|
||||
default -> parseText(rows);
|
||||
};
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// unexpected shape: still show the raw plan as text //
|
||||
////////////////////////////////////////////////////////
|
||||
return (parseText(rows));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The raw plan text, for the "raw" toggle.
|
||||
*******************************************************************************/
|
||||
public static String raw(List<List<Object>> rows)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(List<Object> row : rows == null ? List.<List<Object>>of() : rows)
|
||||
{
|
||||
if(sb.length() > 0)
|
||||
{
|
||||
sb.append('\n');
|
||||
}
|
||||
sb.append(String.join(" | ", row.stream().map(String::valueOf).toList()));
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String firstCell(List<List<Object>> rows)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(List<Object> row : rows)
|
||||
{
|
||||
sb.append(row.isEmpty() ? "" : String.valueOf(row.get(0)));
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Postgres FORMAT JSON: [ { "Plan": {...}, "Planning Time": n, ... } ]
|
||||
*******************************************************************************/
|
||||
static PlanNode parsePostgres(String json)
|
||||
{
|
||||
JSONArray array = new JSONArray(json.trim());
|
||||
JSONObject top = array.getJSONObject(0);
|
||||
PlanNode root = postgresNode(top.getJSONObject("Plan"));
|
||||
for(String key : List.of("Planning Time", "Execution Time"))
|
||||
{
|
||||
if(top.has(key))
|
||||
{
|
||||
root.getExtra().put(key, top.get(key));
|
||||
}
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static PlanNode postgresNode(JSONObject plan)
|
||||
{
|
||||
String label = plan.optString("Node Type", "?");
|
||||
StringBuilder detail = new StringBuilder();
|
||||
if(plan.has("Relation Name"))
|
||||
{
|
||||
detail.append("on ").append(plan.getString("Relation Name"));
|
||||
if(plan.has("Alias") && !plan.getString("Alias").equals(plan.getString("Relation Name")))
|
||||
{
|
||||
detail.append(' ').append(plan.getString("Alias"));
|
||||
}
|
||||
}
|
||||
if(plan.has("Index Name"))
|
||||
{
|
||||
detail.append(detail.length() > 0 ? " " : "").append("using ").append(plan.getString("Index Name"));
|
||||
}
|
||||
if(plan.has("Join Type"))
|
||||
{
|
||||
label = plan.getString("Join Type") + " " + label;
|
||||
}
|
||||
|
||||
PlanNode node = new PlanNode(label, detail.length() == 0 ? null : detail.toString());
|
||||
node.setStartupCost(optDouble(plan, "Startup Cost"));
|
||||
node.setTotalCost(optDouble(plan, "Total Cost"));
|
||||
node.setPlanRows(optDouble(plan, "Plan Rows"));
|
||||
node.setActualTimeMs(optDouble(plan, "Actual Total Time"));
|
||||
node.setActualRows(optDouble(plan, "Actual Rows"));
|
||||
node.setLoops(optDouble(plan, "Actual Loops"));
|
||||
|
||||
Iterator<String> keys = plan.keys();
|
||||
while(keys.hasNext())
|
||||
{
|
||||
String key = keys.next();
|
||||
if(!POSTGRES_KNOWN_KEYS.contains(key))
|
||||
{
|
||||
Object value = plan.get(key);
|
||||
node.getExtra().put(key, value instanceof JSONArray || value instanceof JSONObject ? value.toString() : value);
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray children = plan.optJSONArray("Plans");
|
||||
for(int i = 0; children != null && i < children.length(); i++)
|
||||
{
|
||||
node.withChild(postgresNode(children.getJSONObject(i)));
|
||||
}
|
||||
return (node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MySQL FORMAT=JSON: walk query_block, turning each object that names a
|
||||
** table or an operation into a node.
|
||||
*******************************************************************************/
|
||||
static PlanNode parseMySqlJson(String json)
|
||||
{
|
||||
JSONObject top = new JSONObject(json.trim());
|
||||
JSONObject block = top.optJSONObject("query_block");
|
||||
PlanNode root = new PlanNode("query_block", block == null ? null : "select #" + block.opt("select_id"));
|
||||
if(block != null)
|
||||
{
|
||||
JSONObject costInfo = block.optJSONObject("cost_info");
|
||||
if(costInfo != null)
|
||||
{
|
||||
root.setTotalCost(parseDouble(costInfo.opt("query_cost")));
|
||||
}
|
||||
mysqlChildren(block, root);
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void mysqlChildren(JSONObject object, PlanNode parent)
|
||||
{
|
||||
Iterator<String> keys = object.keys();
|
||||
while(keys.hasNext())
|
||||
{
|
||||
String key = keys.next();
|
||||
Object value = object.get(key);
|
||||
if(value instanceof JSONObject child)
|
||||
{
|
||||
if("table".equals(key))
|
||||
{
|
||||
parent.withChild(mysqlTable(child));
|
||||
}
|
||||
else if(!"cost_info".equals(key))
|
||||
{
|
||||
PlanNode node = new PlanNode(key, null);
|
||||
mysqlChildren(child, node);
|
||||
parent.withChild(node);
|
||||
}
|
||||
}
|
||||
else if(value instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
if(array.get(i) instanceof JSONObject element)
|
||||
{
|
||||
if(element.has("table"))
|
||||
{
|
||||
parent.withChild(mysqlTable(element.getJSONObject("table")));
|
||||
}
|
||||
else
|
||||
{
|
||||
PlanNode node = new PlanNode(key, null);
|
||||
mysqlChildren(element, node);
|
||||
parent.withChild(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static PlanNode mysqlTable(JSONObject table)
|
||||
{
|
||||
PlanNode node = new PlanNode(table.optString("access_type", "table"), table.optString("table_name", null));
|
||||
node.setPlanRows(parseDouble(table.opt("rows_examined_per_scan")));
|
||||
JSONObject costInfo = table.optJSONObject("cost_info");
|
||||
if(costInfo != null)
|
||||
{
|
||||
node.setTotalCost(parseDouble(costInfo.opt("prefix_cost")));
|
||||
}
|
||||
for(String key : List.of("key", "possible_keys", "attached_condition", "filtered"))
|
||||
{
|
||||
if(table.has(key))
|
||||
{
|
||||
node.getExtra().put(key, table.get(key).toString());
|
||||
}
|
||||
}
|
||||
return (node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQLite EXPLAIN QUERY PLAN rows: id, parent, notused, detail.
|
||||
*******************************************************************************/
|
||||
static PlanNode parseSqlite(List<List<Object>> rows)
|
||||
{
|
||||
PlanNode root = new PlanNode("QUERY PLAN", null);
|
||||
Map<Object, PlanNode> byId = new HashMap<>();
|
||||
byId.put("0", root);
|
||||
for(List<Object> row : rows)
|
||||
{
|
||||
String id = String.valueOf(row.get(0));
|
||||
String parent = String.valueOf(row.get(1));
|
||||
String detail = String.valueOf(row.get(row.size() - 1));
|
||||
PlanNode node = new PlanNode(detail, null);
|
||||
byId.put(id, node);
|
||||
byId.getOrDefault(parent, root).withChild(node);
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Text plans: one node per line, nested by leading indentation (MySQL
|
||||
** EXPLAIN ANALYZE uses "-> " lines indented by 4).
|
||||
*******************************************************************************/
|
||||
static PlanNode parseText(List<List<Object>> rows)
|
||||
{
|
||||
PlanNode root = new PlanNode("Plan", null);
|
||||
java.util.Deque<Map.Entry<Integer, PlanNode>> stack = new java.util.ArrayDeque<>();
|
||||
stack.push(Map.entry(-1, root));
|
||||
|
||||
for(String line : raw(rows).split("\n"))
|
||||
{
|
||||
if(line.isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int indent = line.length() - line.stripLeading().length();
|
||||
String text = line.strip();
|
||||
if(text.startsWith("-> "))
|
||||
{
|
||||
text = text.substring(3);
|
||||
}
|
||||
|
||||
PlanNode node = new PlanNode(text, null);
|
||||
while(stack.size() > 1 && stack.peek().getKey() >= indent)
|
||||
{
|
||||
stack.pop();
|
||||
}
|
||||
stack.peek().getValue().withChild(node);
|
||||
stack.push(Map.entry(indent, node));
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Double optDouble(JSONObject object, String key)
|
||||
{
|
||||
return (object.has(key) ? parseDouble(object.get(key)) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Double parseDouble(Object value)
|
||||
{
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (n.doubleValue());
|
||||
}
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Double.parseDouble(value.toString()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.home;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small, theme-neutral HTML pieces for the home widgets: inline styles only
|
||||
** (no host CSS), colors that read in light and dark themes, text that wraps
|
||||
** at any width (no inner horizontal scroll), and every value escaped.
|
||||
*******************************************************************************/
|
||||
public final class HomeHtml
|
||||
{
|
||||
public static final String OK_COLOR = "#16a34a";
|
||||
public static final String BAD_COLOR = "#dc2626";
|
||||
public static final String WARN_COLOR = "#d97706";
|
||||
public static final String INFO_COLOR = "#2563eb";
|
||||
public static final String OFF_COLOR = "#6b7280";
|
||||
|
||||
private static final String MUTED = "opacity:.75";
|
||||
private static final String WRAP = "overflow-wrap:anywhere;min-width:0";
|
||||
private static final String TILE = "flex:1 1 170px;min-width:0;border:1px solid rgba(127,127,127,.25);border-radius:10px;padding:12px 14px";
|
||||
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("h:mm a", Locale.US);
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("MMM d, h:mm a", Locale.US);
|
||||
private static final DateTimeFormatter FULL = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm a", Locale.US);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One summary tile: a label, a value (linked when href is set) and an
|
||||
** optional note.
|
||||
*******************************************************************************/
|
||||
public record Tile(String label, String value, String note, String href)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private HomeHtml()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTML-escape a value (null is empty).
|
||||
*******************************************************************************/
|
||||
public static String escape(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
String text = String.valueOf(value);
|
||||
StringBuilder escaped = new StringBuilder(text.length() + 16);
|
||||
for(char c : text.toCharArray())
|
||||
{
|
||||
switch(c)
|
||||
{
|
||||
case '<' -> escaped.append("<");
|
||||
case '>' -> escaped.append(">");
|
||||
case '&' -> escaped.append("&");
|
||||
case '"' -> escaped.append(""");
|
||||
case '\'' -> escaped.append("'");
|
||||
default -> escaped.append(c);
|
||||
}
|
||||
}
|
||||
return (escaped.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link in the text color, underlined.
|
||||
*******************************************************************************/
|
||||
public static String link(String href, Object text)
|
||||
{
|
||||
return ("<a href=\"" + escape(href) + "\" style=\"color:inherit;text-decoration:underline;text-underline-offset:2px\">" + escape(text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Muted (secondary) text.
|
||||
*******************************************************************************/
|
||||
public static String muted(Object text)
|
||||
{
|
||||
return ("<span style=\"" + MUTED + ";font-size:13px\">" + escape(text) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A pill with a colored border and text.
|
||||
*******************************************************************************/
|
||||
public static String badge(String text, String color)
|
||||
{
|
||||
return ("<span style=\"display:inline-block;white-space:nowrap;padding:1px 8px;border-radius:999px;font-size:12px;font-weight:600;line-height:1.5;"
|
||||
+ "color:" + color + ";border:1px solid " + color + "\">" + escape(text) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A primary line (already HTML) with muted facts (already HTML) after it,
|
||||
** wrapping onto more lines on narrow screens.
|
||||
*******************************************************************************/
|
||||
public static String card(String primaryHtml, String... factsHtml)
|
||||
{
|
||||
List<String> facts = new ArrayList<>();
|
||||
for(String fact : factsHtml)
|
||||
{
|
||||
if(fact != null && !fact.isBlank())
|
||||
{
|
||||
facts.add(fact);
|
||||
}
|
||||
}
|
||||
StringBuilder html = new StringBuilder("<span style=\"display:flex;flex-wrap:wrap;align-items:baseline;column-gap:10px;row-gap:2px;min-width:0\">");
|
||||
html.append("<span style=\"").append(WRAP).append("\">").append(primaryHtml == null ? "" : primaryHtml).append("</span>");
|
||||
if(!facts.isEmpty())
|
||||
{
|
||||
html.append("<span style=\"").append(WRAP).append(';').append(MUTED).append(";font-size:13px\">").append(String.join(" · ", facts)).append("</span>");
|
||||
}
|
||||
return (html.append("</span>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL (or any long text) as monospace, on one line, cut at maxChars and
|
||||
** wrapped anywhere; the full text is in the title tooltip.
|
||||
*******************************************************************************/
|
||||
public static String code(String text, int maxChars)
|
||||
{
|
||||
String oneLine = text == null ? "" : text.replaceAll("\\s+", " ").trim();
|
||||
String shown = oneLine.length() > maxChars ? oneLine.substring(0, maxChars - 1) + "…" : oneLine;
|
||||
return ("<code title=\"" + escape(oneLine.length() > 2000 ? oneLine.substring(0, 2000) : oneLine) + "\" style=\"font-size:12px;" + WRAP + "\">" + escape(shown) + "</code>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wrapping summary tiles: they grow to fill each row, so there are no holes.
|
||||
*******************************************************************************/
|
||||
public static String tiles(List<Tile> tiles)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-wrap:wrap;gap:12px\">");
|
||||
for(Tile tile : tiles)
|
||||
{
|
||||
html.append("<div style=\"").append(TILE).append("\">");
|
||||
html.append("<div style=\"font-size:12px;").append(MUTED).append(";text-transform:uppercase;letter-spacing:.04em;").append(WRAP).append("\">")
|
||||
.append(escape(tile.label())).append("</div>");
|
||||
String value = tile.href() == null ? escape(tile.value())
|
||||
: "<a href=\"" + escape(tile.href()) + "\" style=\"color:inherit;text-decoration:none\">" + escape(tile.value()) + "</a>";
|
||||
html.append("<div style=\"font-size:26px;font-weight:700;line-height:1.3;").append(WRAP).append("\">").append(value).append("</div>");
|
||||
if(tile.note() != null)
|
||||
{
|
||||
html.append("<div style=\"").append(MUTED).append(";font-size:13px;").append(WRAP).append("\">").append(escape(tile.note())).append("</div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link buttons, wrapping.
|
||||
*******************************************************************************/
|
||||
public static String actions(List<String[]> labelsAndHrefs)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-wrap:wrap;gap:8px;margin-top:12px\">");
|
||||
for(String[] action : labelsAndHrefs)
|
||||
{
|
||||
html.append("<a href=\"").append(escape(action[1]))
|
||||
.append("\" style=\"display:inline-flex;align-items:center;padding:8px 12px;border-radius:8px;border:1px solid rgba(127,127,127,.3);")
|
||||
.append("background:rgba(127,127,127,.08);color:inherit;text-decoration:none;font-size:13px;font-weight:500\">")
|
||||
.append(escape(action[0])).append("</a>");
|
||||
}
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An honest empty or unavailable state.
|
||||
*******************************************************************************/
|
||||
public static String emptyState(String message)
|
||||
{
|
||||
return ("<div style=\"padding:18px 4px;" + MUTED + ";font-size:14px;" + WRAP + "\">" + escape(message) + "</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A muted explanatory note under a widget's content.
|
||||
*******************************************************************************/
|
||||
public static String note(String text)
|
||||
{
|
||||
return ("<div style=\"margin-top:10px;" + MUTED + ";font-size:13px;" + WRAP + "\">" + escape(text) + "</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A whole number with thousands separators.
|
||||
*******************************************************************************/
|
||||
public static String number(long value)
|
||||
{
|
||||
return (String.format(Locale.US, "%,d", value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "1 run", "2 runs".
|
||||
*******************************************************************************/
|
||||
public static String plural(long count, String noun)
|
||||
{
|
||||
return (number(count) + " " + noun + (count == 1 ? "" : "s"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A duration in milliseconds: "850 ms", "4.2 s", "3m 05s".
|
||||
*******************************************************************************/
|
||||
public static String duration(Integer millis)
|
||||
{
|
||||
if(millis == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
if(millis < 1000)
|
||||
{
|
||||
return (millis + " ms");
|
||||
}
|
||||
if(millis < 60_000)
|
||||
{
|
||||
return (String.format(Locale.US, "%.1f s", millis / 1000.0));
|
||||
}
|
||||
long seconds = millis / 1000;
|
||||
return (String.format(Locale.US, "%dm %02ds", seconds / 60, seconds % 60));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An instant, compact: the time today, the date and time this year, the
|
||||
** full date otherwise.
|
||||
*******************************************************************************/
|
||||
public static String when(Instant instant, Instant now, ZoneId zoneId)
|
||||
{
|
||||
if(instant == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
ZonedDateTime when = instant.atZone(zoneId);
|
||||
ZonedDateTime today = now.atZone(zoneId);
|
||||
if(when.toLocalDate().equals(today.toLocalDate()))
|
||||
{
|
||||
return (TIME.format(when));
|
||||
}
|
||||
if(when.getYear() == today.getYear())
|
||||
{
|
||||
return (DATE_TIME.format(when));
|
||||
}
|
||||
return (FULL.format(when));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,996 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.home;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionMode;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionStatus;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
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.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.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.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The data behind the SQL Workbench home dashboard and the notebook's
|
||||
** "Open in Runner" widget.
|
||||
**
|
||||
** Everything is read through QQQ's core actions in the viewer's own session,
|
||||
** so the tables' permissions and record security locks decide what each
|
||||
** widget counts and lists: a user sees their own runs and the notebooks
|
||||
** they can open; a workbench admin (all-access key) sees everyone's. A
|
||||
** widget whose table the user may not read says so instead of showing
|
||||
** zeros. Nothing here writes, and no SQL text is logged.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchHome
|
||||
{
|
||||
public static final String KIND_SUMMARY = "summary";
|
||||
public static final String KIND_RUNS_PER_DAY = "runsPerDay";
|
||||
public static final String KIND_RECENT_NOTEBOOKS = "recentNotebooks";
|
||||
public static final String KIND_RECENT_RUNS = "recentRuns";
|
||||
public static final String KIND_SLOWEST = "slowest";
|
||||
public static final String KIND_FAILED = "failed";
|
||||
public static final String KIND_MOST_USED = "mostUsed";
|
||||
public static final String KIND_SHARED = "shared";
|
||||
public static final String KIND_WRITES = "writes";
|
||||
public static final String KIND_BY_USER = "byUser";
|
||||
public static final String KIND_NOTEBOOK_RUNNER = "notebookRunner";
|
||||
|
||||
static final int DAYS = 30;
|
||||
static final int ROWS = 10;
|
||||
static final int USER_ROWS = 25;
|
||||
static final int SCAN_LIMIT = 20_000;
|
||||
static final int SHARE_LIMIT = 1_000;
|
||||
static final int SQL_CHARS = 140;
|
||||
static final int ERROR_CHARS = 200;
|
||||
|
||||
static final List<String> FAILED_STATUSES = List.of(ExecutionStatus.FAILED.name(), ExecutionStatus.TIMED_OUT.name(),
|
||||
ExecutionStatus.REJECTED.name(), ExecutionStatus.ROLLED_BACK.name());
|
||||
|
||||
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM d", Locale.US);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who is looking, and when: the config, the clock's now, the display time
|
||||
** zone, the viewer's user id, and whether they hold the workbench
|
||||
** all-access key (see everyone's notebooks and runs).
|
||||
*******************************************************************************/
|
||||
record Viewer(SqlWorkbenchQBitConfig config, Instant now, ZoneId zoneId, String userId, boolean allAccess)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The (prefixed) name of one of the qbit's tables.
|
||||
*******************************************************************************/
|
||||
String table(String tableName)
|
||||
{
|
||||
return (config.applyPrefix(tableName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start of the window of the last DAYS days.
|
||||
*******************************************************************************/
|
||||
Instant windowStart()
|
||||
{
|
||||
return (today().minusDays(DAYS - 1L).atStartOfDay(zoneId).toInstant());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Today in the display zone.
|
||||
*******************************************************************************/
|
||||
LocalDate today()
|
||||
{
|
||||
return (now.atZone(zoneId).toLocalDate());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchHome()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The viewer of the current session.
|
||||
*******************************************************************************/
|
||||
static Viewer viewer(SqlWorkbenchQBitConfig config, Instant now)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
SqlWorkbenchSecurity.ensureSessionKeys(config, session);
|
||||
String userId = session == null || session.getUser() == null ? null : session.getUser().getIdReference();
|
||||
return (new Viewer(config, now, ValueUtils.getSessionOrInstanceZoneId(), userId, SqlWorkbenchSecurity.hasAllAccess(config)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load one widget's data.
|
||||
*******************************************************************************/
|
||||
static QWidgetData load(String kind, Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (switch(kind == null ? "" : kind)
|
||||
{
|
||||
case KIND_SUMMARY -> summary(viewer, input);
|
||||
case KIND_RUNS_PER_DAY -> runsPerDay(viewer, input);
|
||||
case KIND_RECENT_NOTEBOOKS -> recentNotebooks(viewer, input);
|
||||
case KIND_RECENT_RUNS -> recentRuns(viewer, input);
|
||||
case KIND_SLOWEST -> slowest(viewer, input);
|
||||
case KIND_FAILED -> failed(viewer, input);
|
||||
case KIND_MOST_USED -> mostUsed(viewer, input);
|
||||
case KIND_SHARED -> shared(viewer, input);
|
||||
case KIND_WRITES -> writes(viewer, input);
|
||||
case KIND_BY_USER -> byUser(viewer, input);
|
||||
case KIND_NOTEBOOK_RUNNER -> notebookRunner(viewer, input);
|
||||
default -> new RawHTML(null, HomeHtml.emptyState("Unknown SQL Workbench widget."));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tiles (notebooks, runs today, runs and failures in 7 days, writes in
|
||||
** DAYS days), what they count, and links to the Runner and the tables.
|
||||
*******************************************************************************/
|
||||
static QWidgetData summary(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String notebooks = viewer.table(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
List<HomeHtml.Tile> tiles = new ArrayList<>();
|
||||
|
||||
if(canRead(input, notebooks))
|
||||
{
|
||||
long total = count(notebooks, new QQueryFilter());
|
||||
long mine = viewer.userId() == null ? 0 : count(notebooks, new QQueryFilter(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, viewer.userId())));
|
||||
tiles.add(new HomeHtml.Tile("Notebooks", HomeHtml.number(total),
|
||||
HomeHtml.number(mine) + " yours, " + HomeHtml.number(total - mine) + (viewer.allAccess() ? " by other users" : " shared with you"), "/app/" + notebooks));
|
||||
}
|
||||
|
||||
if(canRead(input, executions))
|
||||
{
|
||||
Instant startOfToday = viewer.today().atStartOfDay(viewer.zoneId()).toInstant();
|
||||
Instant weekStart = viewer.now().minus(Duration.ofDays(7));
|
||||
Instant window = viewer.windowStart();
|
||||
tiles.add(new HomeHtml.Tile("Runs Today", HomeHtml.number(count(executions, since(startOfToday))), null, "/app/" + executions));
|
||||
tiles.add(new HomeHtml.Tile("Runs, 7 Days", HomeHtml.number(count(executions, since(weekStart))),
|
||||
HomeHtml.number(count(executions, since(weekStart).withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, FAILED_STATUSES)))) + " failed", null));
|
||||
|
||||
Writes writes = writesSince(executions, window);
|
||||
tiles.add(new HomeHtml.Tile("Writes, " + DAYS + " Days", HomeHtml.number(writes.count()),
|
||||
Boolean.TRUE.equals(viewer.config().getWriteModeEnabled()) ? HomeHtml.plural(writes.rowsAffected(), "row") + " affected" : "write mode is off", null));
|
||||
}
|
||||
|
||||
if(tiles.isEmpty())
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("You can't read the notebook or execution tables, so there is nothing to summarize.")));
|
||||
}
|
||||
|
||||
String scope = viewer.allAccess()
|
||||
? "You are a workbench admin: these counts include every user's notebooks and runs."
|
||||
: "These counts include your own runs and the notebooks you can open (yours, and those shared with you).";
|
||||
List<String[]> actions = new ArrayList<>();
|
||||
actions.add(new String[] {"Open the Runner", runnerUrl(viewer, null)});
|
||||
actions.add(new String[] {"Notebooks", "/app/" + notebooks});
|
||||
actions.add(new String[] {"Execution Audit", "/app/" + executions});
|
||||
return (new RawHTML(null, HomeHtml.tiles(tiles) + HomeHtml.actions(actions) + HomeHtml.note(scope)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs, failures and committed writes per day over the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData runsPerDay(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
Map<LocalDate, long[]> perDay = new TreeMap<>();
|
||||
for(LocalDate day = viewer.today().minusDays(DAYS - 1L); !day.isAfter(viewer.today()); day = day.plusDays(1))
|
||||
{
|
||||
perDay.put(day, new long[3]);
|
||||
}
|
||||
|
||||
List<QRecord> runs = query(executions, since(viewer.windowStart()), SCAN_LIMIT, Set.of("id", "startTime", "status", "mode"));
|
||||
long total = 0;
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Instant start = run.getValueInstant("startTime");
|
||||
long[] day = start == null ? null : perDay.get(start.atZone(viewer.zoneId()).toLocalDate());
|
||||
if(day != null)
|
||||
{
|
||||
total++;
|
||||
day[0]++;
|
||||
day[1] += FAILED_STATUSES.contains(run.getValueString("status")) ? 1 : 0;
|
||||
day[2] += ExecutionMode.WRITE_COMMIT.name().equals(run.getValueString("mode")) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> labels = new ArrayList<>();
|
||||
List<Number> all = new ArrayList<>();
|
||||
List<Number> failed = new ArrayList<>();
|
||||
List<Number> writes = new ArrayList<>();
|
||||
perDay.forEach((day, counts) ->
|
||||
{
|
||||
labels.add(DAY.format(day));
|
||||
all.add(counts[0]);
|
||||
failed.add(counts[1]);
|
||||
writes.add(counts[2]);
|
||||
});
|
||||
|
||||
ChartData chart = new ChartData().withChartData(new ChartData.Data()
|
||||
.withLabels(labels)
|
||||
.withDatasets(List.of(
|
||||
new ChartData.Data.Dataset().withLabel("Runs").withData(all).withColor("#0062FF"),
|
||||
new ChartData.Data.Dataset().withLabel("Failed").withData(failed).withColor(HomeHtml.BAD_COLOR),
|
||||
new ChartData.Data.Dataset().withLabel("Writes").withData(writes).withColor(HomeHtml.WARN_COLOR))));
|
||||
String sublabel = total == 0 ? "No runs in the last " + DAYS + " days" : HomeHtml.plural(total, "run") + " in the last " + DAYS + " days, by day";
|
||||
if(runs.size() >= SCAN_LIMIT)
|
||||
{
|
||||
sublabel += " (the latest " + HomeHtml.number(SCAN_LIMIT) + " runs)";
|
||||
}
|
||||
chart.setSublabel(sublabel);
|
||||
return (chart);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notebooks changed most recently.
|
||||
*******************************************************************************/
|
||||
static QWidgetData recentNotebooks(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String notebooks = viewer.table(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
if(!canRead(input, notebooks))
|
||||
{
|
||||
return (noAccess("notebooks"));
|
||||
}
|
||||
|
||||
List<QRecord> recent = query(notebooks, new QQueryFilter().withOrderBy(new QFilterOrderBy("modifyDate", false)).withOrderBy(new QFilterOrderBy("id", false)), ROWS,
|
||||
Set.of("id", "title", "description", "ownerUserId", "ownerName", "modifyDate"));
|
||||
Map<Integer, String> sharing = sharing(viewer, input, recent);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord notebook : recent)
|
||||
{
|
||||
String shared = sharing.getOrDefault(notebook.getValueInteger("id"), "personal");
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("notebook", notebookCard(viewer, notebook, HomeHtml.escape(firstLine(notebook.getValueString("description"), 120))));
|
||||
row.put("owner", HomeHtml.escape(ownerLabel(viewer, notebook)));
|
||||
row.put("shared", HomeHtml.badge(shared, "personal".equals(shared) ? HomeHtml.OFF_COLOR : HomeHtml.INFO_COLOR));
|
||||
row.put("modified", HomeHtml.escape(HomeHtml.when(notebook.getValueInstant("modifyDate"), viewer.now(), viewer.zoneId())));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("Notebook", "notebook"), column("Owner", "owner"), column("Sharing", "shared"), column("Modified", "modified")), rows,
|
||||
"No notebooks yet. Create one in the Runner.");
|
||||
return (table.withLinkText("All notebooks").withLinkURL("/app/" + notebooks));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The latest runs.
|
||||
*******************************************************************************/
|
||||
static QWidgetData recentRuns(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
List<QRecord> runs = query(executions, new QQueryFilter().withOrderBy(new QFilterOrderBy("startTime", false)).withOrderBy(new QFilterOrderBy("id", false)), ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
row.put("result", HomeHtml.card(statusBadge(run), HomeHtml.escape(HomeHtml.duration(run.getValueInteger("durationMs"))), HomeHtml.escape(rowsText(run))));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("When", "when"), column("Who", "who"), column("Query", "query"), column("Result", "result")), rows,
|
||||
"No runs yet. Queries run in the Runner are recorded here.");
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The slowest runs of the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData slowest(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
QQueryFilter filter = since(viewer.windowStart())
|
||||
.withCriteria(new QFilterCriteria("durationMs", QCriteriaOperator.IS_NOT_BLANK))
|
||||
.withCriteria(new QFilterCriteria("mode", QCriteriaOperator.NOT_EQUALS, ExecutionMode.WRITE_PREVIEW.name()))
|
||||
.withOrderBy(new QFilterOrderBy("durationMs", false));
|
||||
List<QRecord> runs = query(executions, filter, ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
row.put("duration", HomeHtml.card("<strong>" + HomeHtml.escape(HomeHtml.duration(run.getValueInteger("durationMs"))) + "</strong>", statusBadge(run)));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("Duration", "duration"), column("Query", "query"), column("Who", "who"), column("When", "when")), rows,
|
||||
"No completed runs in the last " + DAYS + " days.");
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed, timed out, rejected and rolled-back runs of the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData failed(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
QQueryFilter filter = since(viewer.windowStart())
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, FAILED_STATUSES))
|
||||
.withOrderBy(new QFilterOrderBy("startTime", false)).withOrderBy(new QFilterOrderBy("id", false));
|
||||
List<QRecord> runs = query(executions, filter, ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
row.put("error", HomeHtml.card(statusBadge(run), HomeHtml.escape(firstLine(run.getValueString("errorMessage"), ERROR_CHARS))));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("When", "when"), column("Query", "query"), column("Error", "error"), column("Who", "who")), rows,
|
||||
"No failed runs in the last " + DAYS + " days.");
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebooks with the most runs in the last DAYS days.
|
||||
*******************************************************************************/
|
||||
static QWidgetData mostUsed(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
Aggregate runs = new Aggregate("id", AggregateOperator.COUNT);
|
||||
Aggregate users = new Aggregate("userId", AggregateOperator.COUNT_DISTINCT);
|
||||
Aggregate lastRun = new Aggregate("startTime", AggregateOperator.MAX);
|
||||
GroupBy notebook = new GroupBy(QFieldType.INTEGER, "notebookId");
|
||||
List<AggregateResult> results = new AggregateAction().execute(new AggregateInput(executions)
|
||||
.withFilter(since(viewer.windowStart()).withCriteria(new QFilterCriteria("notebookId", QCriteriaOperator.IS_NOT_BLANK)))
|
||||
.withAggregate(runs).withAggregate(users).withAggregate(lastRun).withGroupBy(notebook))
|
||||
.getResults();
|
||||
results.sort(Comparator.comparing((AggregateResult r) -> ValueUtils.getValueAsLong(r.getAggregateValue(runs))).reversed());
|
||||
List<AggregateResult> top = results.subList(0, Math.min(ROWS, results.size()));
|
||||
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
top.forEach(r -> ids.add(ValueUtils.getValueAsInteger(r.getGroupByValue(notebook))));
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, ids);
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(AggregateResult result : top)
|
||||
{
|
||||
Integer id = ValueUtils.getValueAsInteger(result.getGroupByValue(notebook));
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("notebook", notebookLink(viewer, id, names.get(id)));
|
||||
row.put("runs", HomeHtml.number(ValueUtils.getValueAsLong(result.getAggregateValue(runs))));
|
||||
row.put("users", HomeHtml.number(ValueUtils.getValueAsLong(result.getAggregateValue(users))));
|
||||
row.put("lastRun", HomeHtml.escape(HomeHtml.when(ValueUtils.getValueAsInstant(result.getAggregateValue(lastRun)), viewer.now(), viewer.zoneId())));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
return (table(List.of(column("Notebook", "notebook"), column("Runs", "runs", "right"), column("Users", "users", "right"), column("Last Run", "lastRun")), rows,
|
||||
"No notebook runs in the last " + DAYS + " days."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebooks shared with users, groups or everyone (the shared library),
|
||||
** most recently shared first.
|
||||
*******************************************************************************/
|
||||
static QWidgetData shared(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String shares = viewer.table(SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
if(!canRead(input, shares))
|
||||
{
|
||||
return (noAccess("notebook shares"));
|
||||
}
|
||||
|
||||
Map<Integer, List<QRecord>> byNotebook = new LinkedHashMap<>();
|
||||
for(QRecord share : query(shares, new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)), SHARE_LIMIT, Set.of("id", "notebookId", "audienceType", "audienceId", "createDate")))
|
||||
{
|
||||
Integer notebookId = share.getValueInteger("notebookId");
|
||||
if(notebookId != null && (byNotebook.containsKey(notebookId) || byNotebook.size() < ROWS))
|
||||
{
|
||||
byNotebook.computeIfAbsent(notebookId, k -> new ArrayList<>()).add(share);
|
||||
}
|
||||
}
|
||||
|
||||
Map<Integer, QRecord> notebooks = notebooks(viewer, input, byNotebook.keySet());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
byNotebook.forEach((id, list) ->
|
||||
{
|
||||
List<String> audiences = new ArrayList<>();
|
||||
for(QRecord share : list)
|
||||
{
|
||||
audiences.add(audienceLabel(share));
|
||||
}
|
||||
QRecord notebook = notebooks.get(id);
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("notebook", notebook == null ? notebookLink(viewer, id, null) : notebookCard(viewer, notebook));
|
||||
row.put("owner", HomeHtml.escape(notebook == null ? "" : ownerLabel(viewer, notebook)));
|
||||
row.put("audience", HomeHtml.escape(String.join(", ", audiences)));
|
||||
row.put("since", HomeHtml.escape(HomeHtml.when(list.get(list.size() - 1).getValueInstant("createDate"), viewer.now(), viewer.zoneId())));
|
||||
rows.add(row);
|
||||
});
|
||||
|
||||
return (table(List.of(column("Notebook", "notebook"), column("Owner", "owner"), column("Shared With", "audience"), column("Shared Since", "since")), rows,
|
||||
"No shared notebooks. Share one from its record (Share Notebook) or in the Runner."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Committed writes (gated write mode): who ran what, when, rows affected.
|
||||
*******************************************************************************/
|
||||
static QWidgetData writes(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("mode", QCriteriaOperator.EQUALS, ExecutionMode.WRITE_COMMIT.name()))
|
||||
.withOrderBy(new QFilterOrderBy("startTime", false)).withOrderBy(new QFilterOrderBy("id", false));
|
||||
List<QRecord> runs = query(executions, filter, ROWS, null);
|
||||
Map<Integer, String> names = notebookTitles(viewer, input, runs);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Map<String, Object> row = runRow(viewer, run, names);
|
||||
Integer affected = run.getValueInteger("rowsAffected");
|
||||
row.put("affected", HomeHtml.card("<strong>" + HomeHtml.escape(affected == null ? "-" : HomeHtml.number(affected)) + "</strong>", statusBadge(run)));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
String writeMode = Boolean.TRUE.equals(viewer.config().getWriteModeEnabled())
|
||||
? "Write mode is on: users with the write permission can preview a write, then commit it within the confirmation window. Every preview and commit is audited."
|
||||
: "Write mode is off on this server, so no new writes can be committed.";
|
||||
TableData table = table(List.of(column("When", "when"), column("Who", "who"), column("Query", "query"), column("Rows Affected", "affected")), rows,
|
||||
"No writes have been committed.");
|
||||
table.withFooterHTML(HomeHtml.note(writeMode));
|
||||
return (table.withLinkText("All executions").withLinkURL("/app/" + executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-user activity over the last DAYS days (the widget is limited to
|
||||
** workbench admins; the locks would show anyone else only themselves).
|
||||
*******************************************************************************/
|
||||
static QWidgetData byUser(Viewer viewer, RenderWidgetInput input) throws QException
|
||||
{
|
||||
String executions = viewer.table(SqlWorkbenchExecution.TABLE_NAME);
|
||||
if(!canRead(input, executions))
|
||||
{
|
||||
return (noAccess("the execution audit"));
|
||||
}
|
||||
|
||||
Map<String, Object[]> users = new LinkedHashMap<>();
|
||||
List<QRecord> runs = query(executions, since(viewer.windowStart()).withOrderBy(new QFilterOrderBy("startTime", false)), SCAN_LIMIT,
|
||||
Set.of("id", "userId", "userName", "status", "mode", "startTime"));
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
String userId = run.getValueString("userId") == null ? "(unknown)" : run.getValueString("userId");
|
||||
Object[] totals = users.computeIfAbsent(userId, k -> new Object[] {run.getValueString("userName"), 0L, 0L, 0L, run.getValueInstant("startTime")});
|
||||
totals[1] = (Long) totals[1] + 1;
|
||||
totals[2] = (Long) totals[2] + (FAILED_STATUSES.contains(run.getValueString("status")) ? 1 : 0);
|
||||
totals[3] = (Long) totals[3] + (ExecutionMode.WRITE_COMMIT.name().equals(run.getValueString("mode")) ? 1 : 0);
|
||||
}
|
||||
|
||||
List<Map.Entry<String, Object[]>> sorted = new ArrayList<>(users.entrySet());
|
||||
sorted.sort(Comparator.comparing((Map.Entry<String, Object[]> e) -> (Long) e.getValue()[1]).reversed());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(Map.Entry<String, Object[]> entry : sorted.subList(0, Math.min(USER_ROWS, sorted.size())))
|
||||
{
|
||||
Object[] totals = entry.getValue();
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
String name = totals[0] == null ? entry.getKey() : String.valueOf(totals[0]);
|
||||
row.put("user", HomeHtml.card(HomeHtml.escape(name), name.equals(entry.getKey()) ? null : HomeHtml.escape(entry.getKey()),
|
||||
HomeHtml.escape("last run " + HomeHtml.when((Instant) totals[4], viewer.now(), viewer.zoneId()))));
|
||||
row.put("runs", HomeHtml.number((Long) totals[1]));
|
||||
row.put("failed", HomeHtml.number((Long) totals[2]));
|
||||
row.put("writes", HomeHtml.number((Long) totals[3]));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
TableData table = table(List.of(column("User", "user"), column("Runs", "runs", "right"), column("Failed", "failed", "right"), column("Writes", "writes", "right")),
|
||||
rows, "No runs in the last " + DAYS + " days.");
|
||||
if(!viewer.allAccess())
|
||||
{
|
||||
table.withFooterHTML(HomeHtml.note("Only your own runs are shown; workbench admins see every user's."));
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** On a notebook's record view: open it in the Runner.
|
||||
*******************************************************************************/
|
||||
static QWidgetData notebookRunner(Viewer viewer, RenderWidgetInput input)
|
||||
{
|
||||
Integer id = ValueUtils.getValueAsInteger(input.getQueryParams() == null ? null : input.getQueryParams().get("id"));
|
||||
if(id == null)
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("Save the notebook to open it in the Runner.")));
|
||||
}
|
||||
return (new RawHTML(null, HomeHtml.actions(List.<String[]>of(new String[] {"Open in Runner", runnerUrl(viewer, id)}))
|
||||
+ HomeHtml.note("The Runner edits and runs this notebook's cells. Runs are recorded in the execution audit.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runner's URL, opening a notebook when an id is given.
|
||||
*******************************************************************************/
|
||||
static String runnerUrl(Viewer viewer, Integer notebookId)
|
||||
{
|
||||
String url = "/app/" + viewer.config().applyPrefix(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME);
|
||||
return (notebookId == null ? url : url + "?notebook=" + notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Committed writes since an instant, and the rows they affected.
|
||||
*******************************************************************************/
|
||||
record Writes(long count, long rowsAffected)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Writes writesSince(String executions, Instant since) throws QException
|
||||
{
|
||||
Aggregate count = new Aggregate("id", AggregateOperator.COUNT);
|
||||
Aggregate affected = new Aggregate("rowsAffected", AggregateOperator.SUM);
|
||||
List<AggregateResult> results = new AggregateAction().execute(new AggregateInput(executions)
|
||||
.withFilter(since(since).withCriteria(new QFilterCriteria("mode", QCriteriaOperator.EQUALS, ExecutionMode.WRITE_COMMIT.name())))
|
||||
.withAggregate(count).withAggregate(affected)).getResults();
|
||||
if(results.isEmpty())
|
||||
{
|
||||
return (new Writes(0, 0));
|
||||
}
|
||||
Long total = ValueUtils.getValueAsLong(results.get(0).getAggregateValue(count));
|
||||
Long rows = ValueUtils.getValueAsLong(results.get(0).getAggregateValue(affected));
|
||||
return (new Writes(total == null ? 0 : total, rows == null ? 0 : rows));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cells every run row shares: when, who, and the query (linked to its
|
||||
** audit record, with its backend and notebook).
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> runRow(Viewer viewer, QRecord run, Map<Integer, String> notebookNames)
|
||||
{
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
Integer notebookId = run.getValueInteger("notebookId");
|
||||
String recordUrl = "/app/" + viewer.table(SqlWorkbenchExecution.TABLE_NAME) + "/" + run.getValueInteger("id");
|
||||
row.put("when", HomeHtml.escape(HomeHtml.when(run.getValueInstant("startTime"), viewer.now(), viewer.zoneId())));
|
||||
row.put("who", HomeHtml.escape(userLabel(viewer, run.getValueString("userId"), run.getValueString("userName"))));
|
||||
row.put("query", HomeHtml.card("<a href=\"" + HomeHtml.escape(recordUrl) + "\" style=\"color:inherit;text-decoration:none\">"
|
||||
+ HomeHtml.code(run.getValueString("sqlText"), SQL_CHARS) + "</a>",
|
||||
HomeHtml.escape(modeLabel(run.getValueString("mode"))),
|
||||
HomeHtml.escape(run.getValueString("backendName")),
|
||||
notebookId == null ? null : notebookLink(viewer, notebookId, notebookNames.get(notebookId))));
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook's title linked to its record, with facts (already HTML) and
|
||||
** an "Open in Runner" link.
|
||||
*******************************************************************************/
|
||||
private static String notebookCard(Viewer viewer, QRecord notebook, String... facts)
|
||||
{
|
||||
Integer id = notebook.getValueInteger("id");
|
||||
List<String> all = new ArrayList<>(List.of(facts));
|
||||
all.add(HomeHtml.link(runnerUrl(viewer, id), "Open in Runner"));
|
||||
return (HomeHtml.card(HomeHtml.link("/app/" + viewer.table(SqlWorkbenchNotebook.TABLE_NAME) + "/" + id, notebook.getValueString("title")), all.toArray(new String[0])));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook link by id: its title when the viewer can read it, otherwise
|
||||
** says so (its runs stay visible in the audit after it is unshared or
|
||||
** deleted).
|
||||
*******************************************************************************/
|
||||
private static String notebookLink(Viewer viewer, Integer id, String title)
|
||||
{
|
||||
if(title == null)
|
||||
{
|
||||
return (HomeHtml.muted("Notebook #" + id + " (deleted, or not visible to you)"));
|
||||
}
|
||||
return (HomeHtml.link("/app/" + viewer.table(SqlWorkbenchNotebook.TABLE_NAME) + "/" + id, title));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "you", or the owner's name.
|
||||
*******************************************************************************/
|
||||
private static String ownerLabel(Viewer viewer, QRecord notebook)
|
||||
{
|
||||
return (userLabel(viewer, notebook.getValueString("ownerUserId"), notebook.getValueString("ownerName")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "you", the user's name, or their id.
|
||||
*******************************************************************************/
|
||||
private static String userLabel(Viewer viewer, String userId, String userName)
|
||||
{
|
||||
if(userId != null && userId.equals(viewer.userId()))
|
||||
{
|
||||
return ("you");
|
||||
}
|
||||
return (userName != null && !userName.isBlank() ? userName : (userId == null ? "unknown user" : userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain label of an execution mode.
|
||||
*******************************************************************************/
|
||||
static String modeLabel(String mode)
|
||||
{
|
||||
if(mode == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
return (switch(mode)
|
||||
{
|
||||
case "READ_ONLY" -> "read-only";
|
||||
case "WRITE_PREVIEW" -> "write preview";
|
||||
case "WRITE_COMMIT" -> "write";
|
||||
case "EXPLAIN" -> "explain";
|
||||
case "EXPLAIN_ANALYZE" -> "explain analyze";
|
||||
default -> mode.toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run's status as a colored badge.
|
||||
*******************************************************************************/
|
||||
static String statusBadge(QRecord run)
|
||||
{
|
||||
String status = run.getValueString("status");
|
||||
if(status == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
String color = switch(status)
|
||||
{
|
||||
case "SUCCEEDED" -> HomeHtml.OK_COLOR;
|
||||
case "RUNNING" -> HomeHtml.INFO_COLOR;
|
||||
case "CANCELLED" -> HomeHtml.OFF_COLOR;
|
||||
default -> HomeHtml.BAD_COLOR;
|
||||
};
|
||||
return (HomeHtml.badge(status.toLowerCase(Locale.ROOT).replace('_', ' '), color));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "12 rows" returned, or affected for a write.
|
||||
*******************************************************************************/
|
||||
private static String rowsText(QRecord run)
|
||||
{
|
||||
Integer affected = run.getValueInteger("rowsAffected");
|
||||
if(affected != null)
|
||||
{
|
||||
return (HomeHtml.plural(affected, "row") + " affected");
|
||||
}
|
||||
Integer returned = run.getValueInteger("rowsReturned");
|
||||
return (returned == null ? "" : HomeHtml.plural(returned, "row"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How each notebook is shared, from the share rows the viewer can see:
|
||||
** "everyone" (the shared library), "shared" (users or groups); absent
|
||||
** when it has no visible shares.
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, String> sharing(Viewer viewer, RenderWidgetInput input, List<QRecord> notebooks) throws QException
|
||||
{
|
||||
Map<Integer, String> sharing = new HashMap<>();
|
||||
String shares = viewer.table(SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
notebooks.forEach(n -> ids.add(n.getValueInteger("id")));
|
||||
if(ids.isEmpty() || !canRead(input, shares))
|
||||
{
|
||||
return (sharing);
|
||||
}
|
||||
for(QRecord share : query(shares, new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.IN, ids)), SHARE_LIMIT, Set.of("id", "notebookId", "audienceType")))
|
||||
{
|
||||
boolean everyone = ShareAudienceType.ALL.name().equals(share.getValueString("audienceType"));
|
||||
sharing.merge(share.getValueInteger("notebookId"), everyone ? "everyone" : "shared", (a, b) -> "everyone".equals(a) ? a : b);
|
||||
}
|
||||
return (sharing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A share's audience in words.
|
||||
*******************************************************************************/
|
||||
private static String audienceLabel(QRecord share)
|
||||
{
|
||||
String type = share.getValueString("audienceType");
|
||||
if(ShareAudienceType.ALL.name().equals(type))
|
||||
{
|
||||
return ("everyone");
|
||||
}
|
||||
String who = share.getValueString("audienceId");
|
||||
return (ShareAudienceType.GROUP.name().equals(type) ? "group " + who : who);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first line of a text, cut at maxChars.
|
||||
*******************************************************************************/
|
||||
static String firstLine(String text, int maxChars)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String line = text.strip().lines().findFirst().orElse("");
|
||||
return (line.length() > maxChars ? line.substring(0, maxChars - 1) + "…" : line);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Titles of the notebooks the runs belong to, as far as the viewer can read
|
||||
** them.
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, String> notebookTitles(Viewer viewer, RenderWidgetInput input, List<QRecord> runs) throws QException
|
||||
{
|
||||
Set<Integer> ids = new LinkedHashSet<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
if(run.getValueInteger("notebookId") != null)
|
||||
{
|
||||
ids.add(run.getValueInteger("notebookId"));
|
||||
}
|
||||
}
|
||||
return (notebookTitles(viewer, input, ids));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, String> notebookTitles(Viewer viewer, RenderWidgetInput input, Set<Integer> ids) throws QException
|
||||
{
|
||||
Map<Integer, String> titles = new HashMap<>();
|
||||
notebooks(viewer, input, ids).forEach((id, notebook) -> titles.put(id, notebook.getValueString("title")));
|
||||
return (titles);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The readable notebooks among some ids (through the notebook locks).
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, QRecord> notebooks(Viewer viewer, RenderWidgetInput input, Set<Integer> ids) throws QException
|
||||
{
|
||||
Map<Integer, QRecord> notebooks = new HashMap<>();
|
||||
String table = viewer.table(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
if(ids.isEmpty() || !canRead(input, table))
|
||||
{
|
||||
return (notebooks);
|
||||
}
|
||||
for(QRecord notebook : query(table, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<Serializable>(ids))), ids.size(),
|
||||
Set.of("id", "title", "ownerUserId", "ownerName")))
|
||||
{
|
||||
notebooks.put(notebook.getValueInteger("id"), notebook);
|
||||
}
|
||||
return (notebooks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the session may read a table.
|
||||
*******************************************************************************/
|
||||
static boolean canRead(RenderWidgetInput input, String tableName)
|
||||
{
|
||||
return (QContext.getQInstance().getTable(tableName) != null && PermissionsHelper.hasTablePermission(input, tableName, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "you can't read this" state.
|
||||
*******************************************************************************/
|
||||
private static QWidgetData noAccess(String what)
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("You don't have permission to read " + what + ".")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A filter on runs started at or after an instant.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter since(Instant since)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria("startTime", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count through the table's locks.
|
||||
*******************************************************************************/
|
||||
private static long count(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
Integer count = new CountAction().execute(new CountInput(tableName).withFilter(filter)).getCount();
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query through the table's locks.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String tableName, QQueryFilter filter, int limit, Set<String> fields) throws QException
|
||||
{
|
||||
QueryInput queryInput = new QueryInput(tableName).withFilter(filter.withLimit(limit));
|
||||
if(fields != null)
|
||||
{
|
||||
queryInput.withFieldNamesToInclude(fields);
|
||||
}
|
||||
return (new QueryAction().execute(queryInput).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table column of HTML cells, top-aligned.
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String header, String accessor)
|
||||
{
|
||||
return (column(header, accessor, "left"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String header, String accessor, String align)
|
||||
{
|
||||
return (new TableData.Column("html", header, accessor, null, align).withVerticalAlign("top"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table without pagination controls, with an honest empty state.
|
||||
*******************************************************************************/
|
||||
private static TableData table(List<TableData.Column> columns, List<Map<String, Object>> rows, String empty)
|
||||
{
|
||||
return (new TableData(null, columns, rows)
|
||||
.withRowsPerPage(Math.max(ROWS, rows.size()))
|
||||
.withHidePaginationDropdown(true)
|
||||
.withNoRowsFoundHTML(HomeHtml.emptyState(empty)));
|
||||
}
|
||||
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench.home;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the SQL Workbench home widgets (and the notebook's "Open in
|
||||
** Runner" widget). Which one is in the widget's "kind" default value; the
|
||||
** data is read in the viewer's session, so permissions and record locks
|
||||
** apply (see SqlWorkbenchHome). The widgets' own permission rules (the
|
||||
** middleware checks them, and the instance metadata hides denied ones)
|
||||
** decide who gets each widget at all.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchHomeWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final String KIND = "kind";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
SqlWorkbenchRuntime runtime = SqlWorkbenchRuntime.get();
|
||||
String kind = null;
|
||||
if(input.getWidgetMetaData() instanceof QWidgetMetaData widget)
|
||||
{
|
||||
Map<String, Serializable> defaults = widget.getDefaultValues();
|
||||
kind = defaults == null || defaults.get(KIND) == null ? null : String.valueOf(defaults.get(KIND));
|
||||
}
|
||||
SqlWorkbenchHome.Viewer viewer = SqlWorkbenchHome.viewer(runtime.getConfig(), runtime.getClock().instant());
|
||||
return (new RenderWidgetOutput(SqlWorkbenchHome.load(kind, viewer, input)));
|
||||
}
|
||||
|
||||
}
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sqlWorkbench app. Its home page is an information dashboard (see
|
||||
** SqlWorkbenchHomeWidgetsMetaDataProducer); its nav lists:
|
||||
**
|
||||
** - Runner: the interactive notebook editor (the SQL_WORKBENCH widget), a
|
||||
** child app of its own;
|
||||
** - the main tables people use directly: Notebooks and the Execution
|
||||
** audit.
|
||||
**
|
||||
** The sub-tables (cells, versions, pinned results, shares) are not in the
|
||||
** nav: they are child lists and relations on a notebook's record view, and
|
||||
** stay standard QQQ tables reachable by URL and the table API. Gated by the
|
||||
** access permission; each table also needs its own read permission to
|
||||
** appear. No processes in the nav (record actions live on the tables).
|
||||
** Hosts can re-parent it (e.g. under an Admin app); its name stays the same.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String APP_NAME = "sqlWorkbench";
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the main tables, in nav order; the rest are reached from a notebook //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
public static final List<String> MAIN_TABLES = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAppMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.applyPrefix(APP_NAME))
|
||||
.withLabel("SQL Workbench")
|
||||
.withIcon(new QIcon("terminal"))
|
||||
.withPermissionRules(config.getAccessPermissionRules())
|
||||
.withWidgets(new ArrayList<>(SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames().stream().map(config::applyPrefix).toList()));
|
||||
|
||||
MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput();
|
||||
if(produced != null)
|
||||
{
|
||||
QAppMetaData runner = produced.get(QAppMetaData.class, config.applyPrefix(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME));
|
||||
if(runner != null)
|
||||
{
|
||||
app.withChild(runner);
|
||||
}
|
||||
for(String tableName : MAIN_TABLES)
|
||||
{
|
||||
QTableMetaData table = produced.get(QTableMetaData.class, config.applyPrefix(tableName));
|
||||
if(table != null)
|
||||
{
|
||||
app.withChild(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** last, so the tables, widgets and Runner app exist in the output
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (900);
|
||||
}
|
||||
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sql_workbench_cell table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCellTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildCellTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (110);
|
||||
}
|
||||
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.developer.SqlWorkbenchDeveloperPanelProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers the "SQL Workbench" record developer panel
|
||||
** ({@link SqlWorkbenchDeveloperPanelProvider}) in the instance's developer
|
||||
** panels (idempotent). The provider decides per table and user whether it
|
||||
** shows.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchDeveloperPanelsMetaDataProducer extends QBitComponentMetaDataProducer<DeveloperPanelsMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public DeveloperPanelsMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (DeveloperPanelsMetaData.ofOrWithNew(qInstance).withProvider(new QCodeReference(SqlWorkbenchDeveloperPanelProvider.class)));
|
||||
}
|
||||
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sql_workbench_execution (audit) table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchExecutionTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildExecutionTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (140);
|
||||
}
|
||||
|
||||
}
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.home.SqlWorkbenchHome;
|
||||
import com.kingsrook.qbits.sqlworkbench.home.SqlWorkbenchHomeWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sqlWorkbench app's home dashboard, in page order, all full width:
|
||||
** summary tiles and links, runs per day, recent notebooks, recent runs,
|
||||
** slowest and failed queries, most-used notebooks, shared notebooks,
|
||||
** committed writes, and (workbench admins only) activity by user. Plus
|
||||
** the "Open in Runner" widget on a notebook's record view.
|
||||
**
|
||||
** The home owns this data; other dashboards should link here rather than
|
||||
** repeat it.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String SUMMARY = "sqlWorkbenchHomeSummary";
|
||||
public static final String RUNS_PER_DAY = "sqlWorkbenchHomeRunsPerDay";
|
||||
public static final String RECENT_NOTEBOOKS = "sqlWorkbenchHomeRecentNotebooks";
|
||||
public static final String RECENT_RUNS = "sqlWorkbenchHomeRecentRuns";
|
||||
public static final String SLOWEST = "sqlWorkbenchHomeSlowestQueries";
|
||||
public static final String FAILED = "sqlWorkbenchHomeFailedQueries";
|
||||
public static final String MOST_USED = "sqlWorkbenchHomeMostUsedNotebooks";
|
||||
public static final String SHARED = "sqlWorkbenchHomeSharedNotebooks";
|
||||
public static final String WRITES = "sqlWorkbenchHomeWrites";
|
||||
public static final String BY_USER = "sqlWorkbenchHomeActivityByUser";
|
||||
public static final String NOTEBOOK_RUNNER = "sqlWorkbenchNotebookOpenInRunner";
|
||||
|
||||
private static final String HTML = WidgetType.HTML.getType();
|
||||
private static final String TABLE = WidgetType.TABLE.getType();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home widget names, in page order (unprefixed).
|
||||
*******************************************************************************/
|
||||
public static List<String> homeWidgetNames()
|
||||
{
|
||||
return (List.of(SUMMARY, RUNS_PER_DAY, RECENT_NOTEBOOKS, RECENT_RUNS, SLOWEST, FAILED, MOST_USED, SHARED, WRITES, BY_USER));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
QPermissionRules access = config.getAccessPermissionRules();
|
||||
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
widgets.add(widget(config, SUMMARY, "SQL Workbench", HTML, SqlWorkbenchHome.KIND_SUMMARY, access));
|
||||
widgets.add(widget(config, RUNS_PER_DAY, "Runs per Day", WidgetType.LINE_CHART.getType(), SqlWorkbenchHome.KIND_RUNS_PER_DAY, access));
|
||||
widgets.add(widget(config, RECENT_NOTEBOOKS, "Recent Notebooks", TABLE, SqlWorkbenchHome.KIND_RECENT_NOTEBOOKS, access));
|
||||
widgets.add(widget(config, RECENT_RUNS, "Recent Runs", TABLE, SqlWorkbenchHome.KIND_RECENT_RUNS, access));
|
||||
widgets.add(widget(config, SLOWEST, "Slowest Queries, 30 Days", TABLE, SqlWorkbenchHome.KIND_SLOWEST, access));
|
||||
widgets.add(widget(config, FAILED, "Failed Queries, 30 Days", TABLE, SqlWorkbenchHome.KIND_FAILED, access));
|
||||
widgets.add(widget(config, MOST_USED, "Most-Used Notebooks, 30 Days", TABLE, SqlWorkbenchHome.KIND_MOST_USED, access));
|
||||
widgets.add(widget(config, SHARED, "Shared Notebooks", TABLE, SqlWorkbenchHome.KIND_SHARED, access));
|
||||
widgets.add(widget(config, WRITES, "Writes", TABLE, SqlWorkbenchHome.KIND_WRITES, access));
|
||||
widgets.add(widget(config, BY_USER, "Activity by User, 30 Days", TABLE, SqlWorkbenchHome.KIND_BY_USER, config.getAdminPermissionRules()));
|
||||
widgets.add(widget(config, NOTEBOOK_RUNNER, "Open in Runner", HTML, SqlWorkbenchHome.KIND_NOTEBOOK_RUNNER, access).withIsCard(false).withShowReloadButton(false));
|
||||
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
widgets.forEach(output::add);
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One full-width widget rendered by SqlWorkbenchHomeWidgetRenderer.
|
||||
*******************************************************************************/
|
||||
private static QWidgetMetaData widget(SqlWorkbenchQBitConfig config, String name, String label, String type, String kind, QPermissionRules permissionRules)
|
||||
{
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(name))
|
||||
.withLabel(label)
|
||||
.withType(type)
|
||||
.withGridColumns(12)
|
||||
.withIsCard(true)
|
||||
.withShowReloadButton(true)
|
||||
.withCodeReference(new QCodeReference(SqlWorkbenchHomeWidgetRenderer.class))
|
||||
.withDefaultValue(SqlWorkbenchHomeWidgetRenderer.KIND, kind)
|
||||
.withPermissionRules(permissionRules));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** with the workbench widget, before the apps
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (310);
|
||||
}
|
||||
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the possible value source over the notebook table, so cell,
|
||||
** version and snapshot rows show the notebook's title.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookPossibleValueSourceProducer extends QBitComponentMetaDataProducer<QPossibleValueSource, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QPossibleValueSource produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildNotebookPossibleValueSource(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the tables
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (200);
|
||||
}
|
||||
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sql_workbench_notebook_share table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookShareTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildNotebookShareTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (100);
|
||||
}
|
||||
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sql_workbench_notebook table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildNotebookTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (100);
|
||||
}
|
||||
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sql_workbench_notebook_version table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookVersionTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildNotebookVersionTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (120);
|
||||
}
|
||||
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchPermissionChecker;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.processes.SqlWorkbenchRecordActionSteps;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record actions on the workbench tables (never nav entries):
|
||||
** - Share Notebook (notebook): share with a user, a group, or everyone (the
|
||||
** shared library); needs share-table insert permission.
|
||||
** - Duplicate Notebook (notebook): a personal copy of a readable notebook;
|
||||
** needs notebook insert permission.
|
||||
** - Restore Version (notebook version): restore it as a new version of the
|
||||
** (owned) notebook; needs notebook edit permission.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRecordActionsMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String SHARE_NOTEBOOK = "sqlWorkbenchShareNotebook";
|
||||
public static final String DUPLICATE_NOTEBOOK = "sqlWorkbenchDuplicateNotebook";
|
||||
public static final String RESTORE_VERSION = "sqlWorkbenchRestoreNotebookVersion";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
String notebook = config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
output.add(recordAction(config.applyPrefix(SHARE_NOTEBOOK), "Share Notebook", "share", notebook,
|
||||
config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME), TablePermissionSubType.INSERT, SqlWorkbenchRecordActionSteps.ShareNotebookStep.class,
|
||||
List.of(new QFieldMetaData("audienceType", QFieldType.STRING).withLabel("Share With").withIsRequired(true).withPossibleValueSourceName(config.applyPrefix(ShareAudienceType.NAME)),
|
||||
new QFieldMetaData("audienceId", QFieldType.STRING).withLabel("User or Group (blank for Everyone)"))));
|
||||
output.add(recordAction(config.applyPrefix(DUPLICATE_NOTEBOOK), "Duplicate Notebook", "content_copy", notebook,
|
||||
notebook, TablePermissionSubType.INSERT, SqlWorkbenchRecordActionSteps.DuplicateNotebookStep.class, List.of()));
|
||||
output.add(recordAction(config.applyPrefix(RESTORE_VERSION), "Restore Version", "restore", config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME),
|
||||
notebook, TablePermissionSubType.EDIT, SqlWorkbenchRecordActionSteps.RestoreVersionStep.class, List.of()));
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A one-record action: optional input form, the backend step (records
|
||||
** loaded through the table's locks), and a result screen.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData recordAction(String name, String label, String icon, String tableName, String permissionTable, TablePermissionSubType permission,
|
||||
Class<? extends BackendStep> step, List<QFieldMetaData> formFields)
|
||||
{
|
||||
List<QStepMetaData> steps = new ArrayList<>();
|
||||
if(!formFields.isEmpty())
|
||||
{
|
||||
steps.add(new QFrontendStepMetaData()
|
||||
.withName("input")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormFields(formFields));
|
||||
}
|
||||
steps.add(new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(tableName)))
|
||||
.withCode(new QCodeReference(step)));
|
||||
steps.add(new QFrontendStepMetaData()
|
||||
.withName("result")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))
|
||||
.withViewField(new QFieldMetaData(SqlWorkbenchRecordActionSteps.RESULT_MESSAGE, QFieldType.STRING).withLabel("Result")));
|
||||
|
||||
return (new QProcessMetaData()
|
||||
.withName(name)
|
||||
.withLabel(label)
|
||||
.withIcon(new QIcon(icon))
|
||||
.withTableName(tableName)
|
||||
.withMinInputRecords(1)
|
||||
.withMaxInputRecords(1)
|
||||
.withPermissionRules(new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(SqlWorkbenchPermissionChecker.requiring(permissionTable + "." + permission.getPermissionSuffix())))
|
||||
.withStepList(steps));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the tables and their joins
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (160);
|
||||
}
|
||||
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sql_workbench_result_snapshot table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchResultSnapshotTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildResultSnapshotTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (130);
|
||||
}
|
||||
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
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.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runner: a child app of the sqlWorkbench app whose page is the
|
||||
** SQL_WORKBENCH widget (the interactive notebook editor the Next UI draws).
|
||||
** It is a custom screen, so it has a nav entry; processes never do. Open a
|
||||
** notebook with /app/sqlWorkbenchRunner?notebook=<id>. Same permission as
|
||||
** the app.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRunnerAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String APP_NAME = "sqlWorkbenchRunner";
|
||||
public static final String LABEL = "Runner";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAppMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
return (new QAppMetaData()
|
||||
.withName(config.applyPrefix(APP_NAME))
|
||||
.withLabel(LABEL)
|
||||
.withIcon(new QIcon("play_circle"))
|
||||
.withPermissionRules(config.getAccessPermissionRules())
|
||||
.withWidgets(List.of(config.applyPrefix(SqlWorkbenchWidgetMetaDataProducer.NAME))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the widget, before the sqlWorkbench app (its parent)
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (850);
|
||||
}
|
||||
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
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.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces what the tables' locks and record views need: the two security
|
||||
** key types, the share audience-type PVS, the notebook joins, and the
|
||||
** child-record list widgets on a notebook's record view.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchSecurityAndJoinsMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
for(QSecurityKeyType keyType : SqlWorkbenchSecurity.buildSecurityKeyTypes(config))
|
||||
{
|
||||
output.add(keyType);
|
||||
}
|
||||
output.add(SqlWorkbenchTableMetaDataHelper.buildAudienceTypePossibleValueSource(config));
|
||||
for(QJoinMetaData join : SqlWorkbenchTableMetaDataHelper.buildJoins(config))
|
||||
{
|
||||
output.add(join);
|
||||
}
|
||||
for(QWidgetMetaDataInterface widget : SqlWorkbenchTableMetaDataHelper.buildChildListWidgets(config))
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
return (output);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the tables, before the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (150);
|
||||
}
|
||||
|
||||
}
|
||||
-486
@@ -1,486 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.customizers.AppendOnlyTableCustomizer;
|
||||
import com.kingsrook.qbits.sqlworkbench.customizers.CellTableCustomizer;
|
||||
import com.kingsrook.qbits.sqlworkbench.customizers.NotebookTableCustomizer;
|
||||
import com.kingsrook.qbits.sqlworkbench.customizers.ShareTableCustomizer;
|
||||
import com.kingsrook.qbits.sqlworkbench.customizers.SnapshotTableCustomizer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
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.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
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.Tier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds the SQL Workbench tables, joins and record-view widgets. Shared by
|
||||
** the component producers and by tests.
|
||||
**
|
||||
** The tables are ordinary QQQ tables (query, view, edit, export, audits,
|
||||
** developer mode), secured by QQQ mechanisms rather than by hiding them:
|
||||
**
|
||||
** - Permissions: QQQ's per-table read / insert / edit / delete permissions
|
||||
** (SqlWorkbenchQBitConfig lists them per role).
|
||||
** - Record security locks, enforced by the core actions and so for every
|
||||
** entry point (table UI, /qqq/v1 table API, processes, PVS lookups,
|
||||
** exports): a notebook (and its cells, versions and snapshots) is visible
|
||||
** to its owner (sql_workbench_user = ownerUserId) OR to an audience it is
|
||||
** shared with (a share row whose audienceKey is one of the session's
|
||||
** sql_workbench_audience values); only the owner can change it. An
|
||||
** execution is visible to its user only. Workbench admins hold the
|
||||
** all-access key and see every row.
|
||||
** - Append-only: versions and the execution audit have no insert / edit /
|
||||
** delete capability, and a customizer rejects those writes in the core
|
||||
** actions unless the qbit itself (system user) makes them.
|
||||
** - Customizers keep the notebook invariants for edits made through the
|
||||
** tables exactly as through the notebook UI (ownership, cell order and
|
||||
** limits, a new version per change, share keys, library visibility).
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchTableMetaDataHelper
|
||||
{
|
||||
public static final String JOIN_NOTEBOOK_CELL = "sql_workbench_notebook_join_sql_workbench_cell";
|
||||
public static final String JOIN_NOTEBOOK_VERSION = "sql_workbench_notebook_join_sql_workbench_notebook_version";
|
||||
public static final String JOIN_NOTEBOOK_SNAPSHOT = "sql_workbench_notebook_join_sql_workbench_result_snapshot";
|
||||
public static final String JOIN_NOTEBOOK_SHARE = "sql_workbench_notebook_join_sql_workbench_notebook_share";
|
||||
public static final String JOIN_SHARE_NOTEBOOK = "sql_workbench_notebook_share_join_sql_workbench_notebook";
|
||||
public static final String JOIN_NOTEBOOK_EXECUTION = "sql_workbench_notebook_join_sql_workbench_execution";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sql_workbench_notebook
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotebookTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchNotebook.TABLE_NAME, "SQL Notebook", "menu_book", SqlWorkbenchNotebook.class), "description", "parametersJson")
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("title")
|
||||
.withRecordSecurityLock(new MultiRecordSecurityLock()
|
||||
.withOperator(MultiRecordSecurityLock.BooleanOperator.OR)
|
||||
.withLock(ownerLock(config, "ownerUserId"))
|
||||
.withLock(shareLock(config, config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME) + ".audienceKey", config.applyPrefix(JOIN_SHARE_NOTEBOOK))))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "title", "description", "ownerName", "visibility")))
|
||||
.withSection(new QFieldSection("runner", "Runner", new QIcon("play_circle"), Tier.T2, null).withWidgetName(config.applyPrefix(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER)))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("defaultBackendName", "parametersJson")))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_CELL, "Cells", "notes"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SHARE, "Shared With", "share"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SNAPSHOT, "Pinned Results", "push_pin"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_VERSION, "Versions", "history"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_EXECUTION, "Runs", "policy"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("ownerUserId", "currentVersion", "createDate", "modifyDate")));
|
||||
table.getField("visibility").setIsEditable(false);
|
||||
table.getField("visibility").setIsRequired(false);
|
||||
table.getField("visibility").setDefaultValue("PERSONAL");
|
||||
table.getField("ownerUserId").setIsRequired(false);
|
||||
table.getField("parametersJson").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
|
||||
return (customize(table, NotebookTableCustomizer.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sql_workbench_cell
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildCellTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchCell.TABLE_NAME, "SQL Notebook Cell", "notes", SqlWorkbenchCell.class), "content")
|
||||
.withRecordLabelFormat("%s cell %s")
|
||||
.withRecordLabelFields("notebookId", "sortOrder")
|
||||
.withUniqueKey(new UniqueKey("notebookId", "cellKey"))
|
||||
.withRecordSecurityLock(childLock(config, JOIN_NOTEBOOK_CELL))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "sortOrder", "cellType", "title")))
|
||||
.withSection(new QFieldSection("content", new QIcon("code"), Tier.T2, List.of("content", "backendName")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("cellKey", "createDate", "modifyDate")));
|
||||
table.getField("cellKey").setIsRequired(false);
|
||||
table.getField("cellType").setIsRequired(false);
|
||||
table.getField("cellType").setDefaultValue("SQL");
|
||||
table.getField("content").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("sql")));
|
||||
return (customize(linkNotebook(config, table), CellTableCustomizer.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sql_workbench_notebook_version (append-only)
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotebookVersionTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchNotebookVersion.TABLE_NAME, "SQL Notebook Version", "history", SqlWorkbenchNotebookVersion.class), "contentJson")
|
||||
.withRecordLabelFormat("%s v%s")
|
||||
.withRecordLabelFields("notebookId", "versionNumber")
|
||||
.withUniqueKey(new UniqueKey("notebookId", "versionNumber"))
|
||||
.withRecordSecurityLock(childLock(config, JOIN_NOTEBOOK_VERSION))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "versionNumber", "comment")))
|
||||
.withSection(new QFieldSection("content", new QIcon("code"), Tier.T2, List.of("contentJson")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("savedByName", "savedByUserId", "createDate")));
|
||||
table.getField("comment").setIsEditable(false);
|
||||
table.getField("contentJson").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
|
||||
appendOnly(table);
|
||||
return (linkNotebook(config, table));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sql_workbench_result_snapshot (pinned from the notebook UI; label edit and
|
||||
** delete through the table)
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildResultSnapshotTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchResultSnapshot.TABLE_NAME, "SQL Result Snapshot", "push_pin", SqlWorkbenchResultSnapshot.class), "sqlText", "resultJson")
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("notebookId", "label")
|
||||
.withRecordSecurityLock(childLock(config, JOIN_NOTEBOOK_SNAPSHOT))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "label", "cellKey")))
|
||||
.withSection(new QFieldSection("result", new QIcon("table_chart"), Tier.T2, List.of("backendName", "rowCount", "truncated", "executionUuid", "sqlText", "resultJson")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createdByUserId", "createDate")));
|
||||
table.getField("sqlText").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("sql")));
|
||||
table.getField("resultJson").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
|
||||
table.withoutCapability(Capability.TABLE_INSERT);
|
||||
return (customize(linkNotebook(config, table), SnapshotTableCustomizer.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sql_workbench_notebook_share: who, besides the owner, can read a notebook.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotebookShareTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, SqlWorkbenchNotebookShare.TABLE_NAME, "SQL Notebook Share", "share", SqlWorkbenchNotebookShare.class)
|
||||
.withRecordLabelFormat("%s - %s")
|
||||
.withRecordLabelFields("notebookId", "audienceKey")
|
||||
.withUniqueKey(new UniqueKey("notebookId", "audienceKey"))
|
||||
.withRecordSecurityLock(new MultiRecordSecurityLock()
|
||||
.withOperator(MultiRecordSecurityLock.BooleanOperator.OR)
|
||||
.withLock(ownerLock(config, config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME) + ".ownerUserId", config.applyPrefix(JOIN_NOTEBOOK_SHARE)))
|
||||
.withLock(shareLock(config, "audienceKey")))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notebookId", "audienceType", "audienceId")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("audienceKey", "createdByName", "createdByUserId", "createDate", "modifyDate")));
|
||||
table.getField("audienceType").setPossibleValueSourceName(config.applyPrefix(ShareAudienceType.NAME));
|
||||
return (customize(linkNotebook(config, table), ShareTableCustomizer.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sql_workbench_execution (the audit; append-only)
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildExecutionTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchExecution.TABLE_NAME, "SQL Execution Audit", "policy", SqlWorkbenchExecution.class), "sqlText", "parametersJson", "errorMessage")
|
||||
.withUniqueKey(new UniqueKey("executionUuid"))
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("mode", "executionUuid")
|
||||
.withRecordSecurityLock(ownerLock(config, "userId"))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "executionUuid", "mode", "status", "backendName")))
|
||||
.withSection(new QFieldSection("run", new QIcon("timer"), Tier.T2, List.of("userName", "userId", "startTime", "endTime", "durationMs", "rowsReturned", "rowsAffected", "truncated",
|
||||
"statementCount", "statementKinds", "notebookId", "cellKey", "clientIp")))
|
||||
.withSection(new QFieldSection("sql", new QIcon("code"), Tier.T2, List.of("sqlText", "sqlHash", "parametersJson", "errorMessage")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate")));
|
||||
table.getField("sqlText").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("sql")));
|
||||
table.getField("notebookId").setPossibleValueSourceName(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME));
|
||||
appendOnly(table);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value source over the notebook table, for notebookId fields.
|
||||
** Lookups go through the table's locks, so they only show readable
|
||||
** notebooks.
|
||||
*******************************************************************************/
|
||||
public static QPossibleValueSource buildNotebookPossibleValueSource(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
return (QPossibleValueSource.newForTable(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value source for share audience types.
|
||||
*******************************************************************************/
|
||||
public static QPossibleValueSource buildAudienceTypePossibleValueSource(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
return (QPossibleValueSource.newForEnum(config.applyPrefix(ShareAudienceType.NAME), ShareAudienceType.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All six tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> buildAllTables(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
return (List.of(buildNotebookTable(config), buildCellTable(config), buildNotebookVersionTable(config), buildResultSnapshotTable(config),
|
||||
buildNotebookShareTable(config), buildExecutionTable(config)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The joins: notebook to each child (one-to-many), notebook to its runs in
|
||||
** the execution audit (one-to-many; the audit is not foreign-keyed, so it
|
||||
** outlives the notebook), and share to notebook (many-to-one, the
|
||||
** direction the share locks walk).
|
||||
*******************************************************************************/
|
||||
public static List<QJoinMetaData> buildJoins(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
String notebook = config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
Map<String, String> children = new LinkedHashMap<>();
|
||||
children.put(JOIN_NOTEBOOK_CELL, SqlWorkbenchCell.TABLE_NAME);
|
||||
children.put(JOIN_NOTEBOOK_VERSION, SqlWorkbenchNotebookVersion.TABLE_NAME);
|
||||
children.put(JOIN_NOTEBOOK_SNAPSHOT, SqlWorkbenchResultSnapshot.TABLE_NAME);
|
||||
children.put(JOIN_NOTEBOOK_SHARE, SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
children.put(JOIN_NOTEBOOK_EXECUTION, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
List<QJoinMetaData> joins = new ArrayList<>();
|
||||
for(Map.Entry<String, String> entry : children.entrySet())
|
||||
{
|
||||
joins.add(new QJoinMetaData()
|
||||
.withName(config.applyPrefix(entry.getKey()))
|
||||
.withLeftTable(notebook)
|
||||
.withRightTable(config.applyPrefix(entry.getValue()))
|
||||
.withType(JoinType.ONE_TO_MANY)
|
||||
.withJoinOn(new JoinOn("id", "notebookId")));
|
||||
}
|
||||
joins.add(new QJoinMetaData()
|
||||
.withName(config.applyPrefix(JOIN_SHARE_NOTEBOOK))
|
||||
.withLeftTable(config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME))
|
||||
.withRightTable(notebook)
|
||||
.withType(JoinType.MANY_TO_ONE)
|
||||
.withJoinOn(new JoinOn("notebookId", "id")));
|
||||
return (joins);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Child-record list widgets on the notebook's record view: cells (add and
|
||||
** edit in place), shares (add, for users who may share), pinned results,
|
||||
** versions, and runs (the notebook's executions the viewer may see: their
|
||||
** own, or everyone's for workbench admins).
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaDataInterface> buildChildListWidgets(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
Map<String, QJoinMetaData> joins = new LinkedHashMap<>();
|
||||
for(QJoinMetaData join : buildJoins(config))
|
||||
{
|
||||
joins.put(join.getName(), join);
|
||||
}
|
||||
|
||||
List<QWidgetMetaDataInterface> widgets = new ArrayList<>();
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_CELL)))
|
||||
.withLabel("Cells").withCanAddChildRecord(true).withMaxRows(200)
|
||||
.withOnlyIncludeFieldNames(List.of("id", "sortOrder", "cellType", "title", "backendName", "modifyDate"))
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_SHARE)))
|
||||
.withLabel("Shared With").withCanAddChildRecord(true)
|
||||
.withOnlyIncludeFieldNames(List.of("id", "audienceType", "audienceId", "createdByName", "createDate"))
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_SNAPSHOT)))
|
||||
.withLabel("Pinned Results")
|
||||
.withOnlyIncludeFieldNames(List.of("id", "label", "cellKey", "rowCount", "truncated", "createDate"))
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_VERSION)))
|
||||
.withLabel("Versions").withMaxRows(25)
|
||||
.withOrderBys(List.of(new QFilterOrderBy("versionNumber", false)))
|
||||
.withOnlyIncludeFieldNames(List.of("id", "versionNumber", "comment", "savedByName", "createDate"))
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_EXECUTION)))
|
||||
.withLabel("Runs").withMaxRows(25)
|
||||
.withOrderBys(List.of(new QFilterOrderBy("startTime", false), new QFilterOrderBy("id", false)))
|
||||
.withOnlyIncludeFieldNames(List.of("id", "startTime", "userName", "mode", "status", "durationMs", "rowsReturned", "rowsAffected", "cellKey"))
|
||||
.getWidgetMetaData());
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(SqlWorkbenchQBitConfig config, String name, String label, String icon, Class<? extends QRecordEntity> entity) throws QException
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData();
|
||||
table.withName(config.applyPrefix(name))
|
||||
.withLabel(label)
|
||||
.withIcon(new QIcon(icon))
|
||||
.withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id")
|
||||
.withPermissionRules(config.getTablePermissionRules())
|
||||
.withFieldsFromEntity(entity);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner lock: the session's sql_workbench_user value must equal the field
|
||||
** (read and write).
|
||||
*******************************************************************************/
|
||||
static RecordSecurityLock ownerLock(SqlWorkbenchQBitConfig config, String fieldName, String... joinNameChain)
|
||||
{
|
||||
RecordSecurityLock lock = new RecordSecurityLock()
|
||||
.withSecurityKeyType(config.getUserSecurityKeyTypeName())
|
||||
.withFieldName(fieldName)
|
||||
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE);
|
||||
if(joinNameChain.length > 0)
|
||||
{
|
||||
lock.setJoinNameChain(List.of(joinNameChain));
|
||||
}
|
||||
return (lock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share lock: a share row's audienceKey is one of the session's
|
||||
** sql_workbench_audience values (read only).
|
||||
*******************************************************************************/
|
||||
static RecordSecurityLock shareLock(SqlWorkbenchQBitConfig config, String fieldName, String... joinNameChain)
|
||||
{
|
||||
RecordSecurityLock lock = new RecordSecurityLock()
|
||||
.withSecurityKeyType(config.getAudienceSecurityKeyTypeName())
|
||||
.withFieldName(fieldName)
|
||||
.withLockScope(RecordSecurityLock.LockScope.READ);
|
||||
if(joinNameChain.length > 0)
|
||||
{
|
||||
lock.setJoinNameChain(List.of(joinNameChain));
|
||||
}
|
||||
return (lock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lock for a notebook child table: the notebook's owner, or (read only)
|
||||
** an audience the notebook is shared with.
|
||||
*******************************************************************************/
|
||||
private static MultiRecordSecurityLock childLock(SqlWorkbenchQBitConfig config, String notebookJoinChild)
|
||||
{
|
||||
return (new MultiRecordSecurityLock()
|
||||
.withOperator(MultiRecordSecurityLock.BooleanOperator.OR)
|
||||
.withLock(ownerLock(config, config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME) + ".ownerUserId", config.applyPrefix(notebookJoinChild)))
|
||||
.withLock(shareLock(config, config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME) + ".audienceKey", config.applyPrefix(JOIN_SHARE_NOTEBOOK), config.applyPrefix(notebookJoinChild))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A record-view section that shows one of the child-list widgets.
|
||||
*******************************************************************************/
|
||||
private static QFieldSection widgetSection(SqlWorkbenchQBitConfig config, String joinName, String label, String icon)
|
||||
{
|
||||
return (new QFieldSection(config.applyPrefix(joinName), label, new QIcon(icon), Tier.T2, null).withWidgetName(config.applyPrefix(joinName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the qbit (system user) writes this table: the capabilities hide
|
||||
** writes in the UI / API, the customizer rejects them in the core actions.
|
||||
*******************************************************************************/
|
||||
private static void appendOnly(QTableMetaData table)
|
||||
{
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
customize(table, AppendOnlyTableCustomizer.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register one customizer class for every insert / update / delete hook.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData customize(QTableMetaData table, Class<?> customizer)
|
||||
{
|
||||
QCodeReference reference = new QCodeReference(customizer);
|
||||
for(TableCustomizers role : List.of(TableCustomizers.PRE_INSERT_RECORD, TableCustomizers.POST_INSERT_RECORD, TableCustomizers.PRE_UPDATE_RECORD,
|
||||
TableCustomizers.POST_UPDATE_RECORD, TableCustomizers.PRE_DELETE_RECORD, TableCustomizers.POST_DELETE_RECORD))
|
||||
{
|
||||
table.withCustomizer(role, reference);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Point notebookId at the (possibly prefixed) notebook PVS.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData linkNotebook(SqlWorkbenchQBitConfig config, QTableMetaData table)
|
||||
{
|
||||
QFieldMetaData field = table.getField("notebookId");
|
||||
field.setPossibleValueSourceName(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make String fields TEXT (unbounded).
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData text(QTableMetaData table, String... names)
|
||||
{
|
||||
for(String name : names)
|
||||
{
|
||||
table.getField(name).setType(QFieldType.TEXT);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
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.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench widget, of type SQL_WORKBENCH: the Next UI renders the whole
|
||||
** notebook UI for it from the /qqq/v1/sql-workbench endpoints (as it renders
|
||||
** ESB_OVERVIEW from the ESB endpoints). Same permission as the app.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchWidgetMetaDataProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String NAME = "sqlWorkbenchWidget";
|
||||
public static final String TYPE = "SQL_WORKBENCH";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(NAME))
|
||||
.withLabel("SQL Workbench")
|
||||
.withType(TYPE)
|
||||
.withGridColumns(12)
|
||||
.withIsCard(false)
|
||||
.withCodeReference(new QCodeReference(SqlWorkbenchWidgetRenderer.class))
|
||||
.withPermissionRules(config.getAccessPermissionRules()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the tables and PVS, before the app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (300);
|
||||
}
|
||||
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the SQL Workbench widget. The frontend draws the widget from
|
||||
** the (permission-checked) /qqq/v1/sql-workbench endpoints, so the widget's
|
||||
** own data is just its type.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new SqlWorkbenchWidgetData()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget data: its type, and nothing else.
|
||||
*******************************************************************************/
|
||||
public static class SqlWorkbenchWidgetData extends QWidgetData
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (SqlWorkbenchWidgetMetaDataProducer.TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who a notebook share grants read access to.
|
||||
*******************************************************************************/
|
||||
public enum ShareAudienceType implements PossibleValueEnum<String>
|
||||
{
|
||||
USER("User", "user"),
|
||||
GROUP("Group", "group"),
|
||||
ALL("Everyone (shared library)", "all");
|
||||
|
||||
public static final String NAME = "sql_workbench_share_audience_type";
|
||||
|
||||
/*******************************************************************************
|
||||
** The possible-value source name before 1.0.0-RC.3.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
public static final String LEGACY_NAME = "sqlWorkbenchShareAudienceType";
|
||||
|
||||
private final String label;
|
||||
private final String keyPrefix;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
ShareAudienceType(String label, String keyPrefix)
|
||||
{
|
||||
this.label = label;
|
||||
this.keyPrefix = keyPrefix;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The audience key for this type and id: "user:alice", "group:analysts",
|
||||
** or "all". Ids are trimmed and lower-cased, as are the session's values.
|
||||
*******************************************************************************/
|
||||
public String audienceKey(String audienceId)
|
||||
{
|
||||
if(this == ALL)
|
||||
{
|
||||
return (keyPrefix);
|
||||
}
|
||||
return (keyPrefix + ":" + normalize(audienceId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Trim and lower-case an audience id (null stays null).
|
||||
*******************************************************************************/
|
||||
public static String normalize(String audienceId)
|
||||
{
|
||||
return (audienceId == null ? null : audienceId.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up by name, ignoring case; null when unknown.
|
||||
*******************************************************************************/
|
||||
public static ShareAudienceType of(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(ShareAudienceType type : values())
|
||||
{
|
||||
if(type.name().equalsIgnoreCase(value.toString().trim()))
|
||||
{
|
||||
return (type);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One cell of a notebook: SQL or markdown, in sortOrder.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCell extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sql_workbench_cell";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchCell";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 64)
|
||||
private String cellKey;
|
||||
|
||||
@QField()
|
||||
private Integer sortOrder;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String cellType;
|
||||
|
||||
@QField(maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String title;
|
||||
|
||||
@QField()
|
||||
private String content;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String backendName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellKey
|
||||
*******************************************************************************/
|
||||
public String getCellKey()
|
||||
{
|
||||
return (this.cellKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellKey
|
||||
*******************************************************************************/
|
||||
public void setCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellKey
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sortOrder
|
||||
*******************************************************************************/
|
||||
public Integer getSortOrder()
|
||||
{
|
||||
return (this.sortOrder);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sortOrder
|
||||
*******************************************************************************/
|
||||
public void setSortOrder(Integer sortOrder)
|
||||
{
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sortOrder
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withSortOrder(Integer sortOrder)
|
||||
{
|
||||
this.sortOrder = sortOrder;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellType
|
||||
*******************************************************************************/
|
||||
public String getCellType()
|
||||
{
|
||||
return (this.cellType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellType
|
||||
*******************************************************************************/
|
||||
public void setCellType(String cellType)
|
||||
{
|
||||
this.cellType = cellType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellType
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withCellType(String cellType)
|
||||
{
|
||||
this.cellType = cellType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for content
|
||||
*******************************************************************************/
|
||||
public String getContent()
|
||||
{
|
||||
return (this.content);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for content
|
||||
*******************************************************************************/
|
||||
public void setContent(String content)
|
||||
{
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for content
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withContent(String content)
|
||||
{
|
||||
this.content = content;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (this.backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,844 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit record of one SQL Workbench execution: who, when, where, what SQL,
|
||||
** in which mode, and how it ended. Append-only: written before the SQL runs
|
||||
** and completed after.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchExecution extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sql_workbench_execution";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchExecution";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isEditable = false, maxLength = 36)
|
||||
private String executionUuid;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String userName;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String backendName;
|
||||
|
||||
@QField(isEditable = false, maxLength = 24)
|
||||
private String mode;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String status;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String sqlText;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String sqlHash;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String parametersJson;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer statementCount;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String statementKinds;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String cellKey;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant startTime;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant endTime;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer durationMs;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer rowsReturned;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer rowsAffected;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Boolean truncated;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String errorMessage;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String clientIp;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for executionUuid
|
||||
*******************************************************************************/
|
||||
public String getExecutionUuid()
|
||||
{
|
||||
return (this.executionUuid);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public void setExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userName
|
||||
*******************************************************************************/
|
||||
public String getUserName()
|
||||
{
|
||||
return (this.userName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userName
|
||||
*******************************************************************************/
|
||||
public void setUserName(String userName)
|
||||
{
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withUserName(String userName)
|
||||
{
|
||||
this.userName = userName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (this.backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for mode
|
||||
*******************************************************************************/
|
||||
public String getMode()
|
||||
{
|
||||
return (this.mode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for mode
|
||||
*******************************************************************************/
|
||||
public void setMode(String mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for mode
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withMode(String mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (this.status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sqlText
|
||||
*******************************************************************************/
|
||||
public String getSqlText()
|
||||
{
|
||||
return (this.sqlText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sqlText
|
||||
*******************************************************************************/
|
||||
public void setSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sqlText
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sqlHash
|
||||
*******************************************************************************/
|
||||
public String getSqlHash()
|
||||
{
|
||||
return (this.sqlHash);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sqlHash
|
||||
*******************************************************************************/
|
||||
public void setSqlHash(String sqlHash)
|
||||
{
|
||||
this.sqlHash = sqlHash;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sqlHash
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withSqlHash(String sqlHash)
|
||||
{
|
||||
this.sqlHash = sqlHash;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parametersJson
|
||||
*******************************************************************************/
|
||||
public String getParametersJson()
|
||||
{
|
||||
return (this.parametersJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public void setParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for statementCount
|
||||
*******************************************************************************/
|
||||
public Integer getStatementCount()
|
||||
{
|
||||
return (this.statementCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for statementCount
|
||||
*******************************************************************************/
|
||||
public void setStatementCount(Integer statementCount)
|
||||
{
|
||||
this.statementCount = statementCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for statementCount
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStatementCount(Integer statementCount)
|
||||
{
|
||||
this.statementCount = statementCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for statementKinds
|
||||
*******************************************************************************/
|
||||
public String getStatementKinds()
|
||||
{
|
||||
return (this.statementKinds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for statementKinds
|
||||
*******************************************************************************/
|
||||
public void setStatementKinds(String statementKinds)
|
||||
{
|
||||
this.statementKinds = statementKinds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for statementKinds
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStatementKinds(String statementKinds)
|
||||
{
|
||||
this.statementKinds = statementKinds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellKey
|
||||
*******************************************************************************/
|
||||
public String getCellKey()
|
||||
{
|
||||
return (this.cellKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellKey
|
||||
*******************************************************************************/
|
||||
public void setCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellKey
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startTime
|
||||
*******************************************************************************/
|
||||
public Instant getStartTime()
|
||||
{
|
||||
return (this.startTime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for startTime
|
||||
*******************************************************************************/
|
||||
public void setStartTime(Instant startTime)
|
||||
{
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for startTime
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStartTime(Instant startTime)
|
||||
{
|
||||
this.startTime = startTime;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for endTime
|
||||
*******************************************************************************/
|
||||
public Instant getEndTime()
|
||||
{
|
||||
return (this.endTime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for endTime
|
||||
*******************************************************************************/
|
||||
public void setEndTime(Instant endTime)
|
||||
{
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for endTime
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withEndTime(Instant endTime)
|
||||
{
|
||||
this.endTime = endTime;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for durationMs
|
||||
*******************************************************************************/
|
||||
public Integer getDurationMs()
|
||||
{
|
||||
return (this.durationMs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for durationMs
|
||||
*******************************************************************************/
|
||||
public void setDurationMs(Integer durationMs)
|
||||
{
|
||||
this.durationMs = durationMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for durationMs
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withDurationMs(Integer durationMs)
|
||||
{
|
||||
this.durationMs = durationMs;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowsReturned
|
||||
*******************************************************************************/
|
||||
public Integer getRowsReturned()
|
||||
{
|
||||
return (this.rowsReturned);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rowsReturned
|
||||
*******************************************************************************/
|
||||
public void setRowsReturned(Integer rowsReturned)
|
||||
{
|
||||
this.rowsReturned = rowsReturned;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowsReturned
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withRowsReturned(Integer rowsReturned)
|
||||
{
|
||||
this.rowsReturned = rowsReturned;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowsAffected
|
||||
*******************************************************************************/
|
||||
public Integer getRowsAffected()
|
||||
{
|
||||
return (this.rowsAffected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rowsAffected
|
||||
*******************************************************************************/
|
||||
public void setRowsAffected(Integer rowsAffected)
|
||||
{
|
||||
this.rowsAffected = rowsAffected;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowsAffected
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withRowsAffected(Integer rowsAffected)
|
||||
{
|
||||
this.rowsAffected = rowsAffected;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated
|
||||
*******************************************************************************/
|
||||
public Boolean getTruncated()
|
||||
{
|
||||
return (this.truncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for truncated
|
||||
*******************************************************************************/
|
||||
public void setTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for truncated
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errorMessage
|
||||
*******************************************************************************/
|
||||
public String getErrorMessage()
|
||||
{
|
||||
return (this.errorMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for errorMessage
|
||||
*******************************************************************************/
|
||||
public void setErrorMessage(String errorMessage)
|
||||
{
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for errorMessage
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withErrorMessage(String errorMessage)
|
||||
{
|
||||
this.errorMessage = errorMessage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientIp
|
||||
*******************************************************************************/
|
||||
public String getClientIp()
|
||||
{
|
||||
return (this.clientIp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clientIp
|
||||
*******************************************************************************/
|
||||
public void setClientIp(String clientIp)
|
||||
{
|
||||
this.clientIp = clientIp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientIp
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withClientIp(String clientIp)
|
||||
{
|
||||
this.clientIp = clientIp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A saved notebook: ordered SQL and markdown cells, owned by a user, personal or
|
||||
** shared through the library.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebook extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebook";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String title;
|
||||
|
||||
@QField()
|
||||
private String description;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, maxLength = 255)
|
||||
private String ownerUserId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String ownerName;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String visibility;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String defaultBackendName;
|
||||
|
||||
@QField()
|
||||
private String parametersJson;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer currentVersion;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for description
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ownerUserId
|
||||
*******************************************************************************/
|
||||
public String getOwnerUserId()
|
||||
{
|
||||
return (this.ownerUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ownerUserId
|
||||
*******************************************************************************/
|
||||
public void setOwnerUserId(String ownerUserId)
|
||||
{
|
||||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ownerUserId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withOwnerUserId(String ownerUserId)
|
||||
{
|
||||
this.ownerUserId = ownerUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ownerName
|
||||
*******************************************************************************/
|
||||
public String getOwnerName()
|
||||
{
|
||||
return (this.ownerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ownerName
|
||||
*******************************************************************************/
|
||||
public void setOwnerName(String ownerName)
|
||||
{
|
||||
this.ownerName = ownerName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ownerName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withOwnerName(String ownerName)
|
||||
{
|
||||
this.ownerName = ownerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for visibility
|
||||
*******************************************************************************/
|
||||
public String getVisibility()
|
||||
{
|
||||
return (this.visibility);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for visibility
|
||||
*******************************************************************************/
|
||||
public void setVisibility(String visibility)
|
||||
{
|
||||
this.visibility = visibility;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for visibility
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withVisibility(String visibility)
|
||||
{
|
||||
this.visibility = visibility;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultBackendName
|
||||
*******************************************************************************/
|
||||
public String getDefaultBackendName()
|
||||
{
|
||||
return (this.defaultBackendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for defaultBackendName
|
||||
*******************************************************************************/
|
||||
public void setDefaultBackendName(String defaultBackendName)
|
||||
{
|
||||
this.defaultBackendName = defaultBackendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultBackendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withDefaultBackendName(String defaultBackendName)
|
||||
{
|
||||
this.defaultBackendName = defaultBackendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parametersJson
|
||||
*******************************************************************************/
|
||||
public String getParametersJson()
|
||||
{
|
||||
return (this.parametersJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public void setParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for currentVersion
|
||||
*******************************************************************************/
|
||||
public Integer getCurrentVersion()
|
||||
{
|
||||
return (this.currentVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for currentVersion
|
||||
*******************************************************************************/
|
||||
public void setCurrentVersion(Integer currentVersion)
|
||||
{
|
||||
this.currentVersion = currentVersion;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for currentVersion
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withCurrentVersion(Integer currentVersion)
|
||||
{
|
||||
this.currentVersion = currentVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One audience a notebook is shared with (read-only): a user, a group, or
|
||||
** everyone with workbench access (audienceType ALL - the shared library).
|
||||
** audienceKey ("user:alice", "group:analysts", "all") is computed by the
|
||||
** table customizer and is what the record security locks match against the
|
||||
** session's sql_workbench_audience key values.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookShare extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook_share";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebookShare";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = ShareAudienceType.NAME)
|
||||
private String audienceType;
|
||||
|
||||
@QField(maxLength = 255, label = "User or Group")
|
||||
private String audienceId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 300)
|
||||
private String audienceKey;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String createdByUserId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String createdByName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audienceType
|
||||
*******************************************************************************/
|
||||
public String getAudienceType()
|
||||
{
|
||||
return (this.audienceType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audienceType
|
||||
*******************************************************************************/
|
||||
public void setAudienceType(String audienceType)
|
||||
{
|
||||
this.audienceType = audienceType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audienceType
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withAudienceType(String audienceType)
|
||||
{
|
||||
this.audienceType = audienceType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audienceId
|
||||
*******************************************************************************/
|
||||
public String getAudienceId()
|
||||
{
|
||||
return (this.audienceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audienceId
|
||||
*******************************************************************************/
|
||||
public void setAudienceId(String audienceId)
|
||||
{
|
||||
this.audienceId = audienceId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audienceId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withAudienceId(String audienceId)
|
||||
{
|
||||
this.audienceId = audienceId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audienceKey
|
||||
*******************************************************************************/
|
||||
public String getAudienceKey()
|
||||
{
|
||||
return (this.audienceKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audienceKey
|
||||
*******************************************************************************/
|
||||
public void setAudienceKey(String audienceKey)
|
||||
{
|
||||
this.audienceKey = audienceKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audienceKey
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withAudienceKey(String audienceKey)
|
||||
{
|
||||
this.audienceKey = audienceKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public String getCreatedByUserId()
|
||||
{
|
||||
return (this.createdByUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public void setCreatedByUserId(String createdByUserId)
|
||||
{
|
||||
this.createdByUserId = createdByUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withCreatedByUserId(String createdByUserId)
|
||||
{
|
||||
this.createdByUserId = createdByUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createdByName
|
||||
*******************************************************************************/
|
||||
public String getCreatedByName()
|
||||
{
|
||||
return (this.createdByName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createdByName
|
||||
*******************************************************************************/
|
||||
public void setCreatedByName(String createdByName)
|
||||
{
|
||||
this.createdByName = createdByName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createdByName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withCreatedByName(String createdByName)
|
||||
{
|
||||
this.createdByName = createdByName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookShare withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
-332
@@ -1,332 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An immutable snapshot of a notebook and its cells, written on every save.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookVersion extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook_version";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebookVersion";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isRequired = true, isEditable = false)
|
||||
private Integer versionNumber;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String contentJson;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String savedByUserId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String savedByName;
|
||||
|
||||
@QField(maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String comment;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for versionNumber
|
||||
*******************************************************************************/
|
||||
public Integer getVersionNumber()
|
||||
{
|
||||
return (this.versionNumber);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for versionNumber
|
||||
*******************************************************************************/
|
||||
public void setVersionNumber(Integer versionNumber)
|
||||
{
|
||||
this.versionNumber = versionNumber;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for versionNumber
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withVersionNumber(Integer versionNumber)
|
||||
{
|
||||
this.versionNumber = versionNumber;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for contentJson
|
||||
*******************************************************************************/
|
||||
public String getContentJson()
|
||||
{
|
||||
return (this.contentJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for contentJson
|
||||
*******************************************************************************/
|
||||
public void setContentJson(String contentJson)
|
||||
{
|
||||
this.contentJson = contentJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for contentJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withContentJson(String contentJson)
|
||||
{
|
||||
this.contentJson = contentJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for savedByUserId
|
||||
*******************************************************************************/
|
||||
public String getSavedByUserId()
|
||||
{
|
||||
return (this.savedByUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for savedByUserId
|
||||
*******************************************************************************/
|
||||
public void setSavedByUserId(String savedByUserId)
|
||||
{
|
||||
this.savedByUserId = savedByUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for savedByUserId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withSavedByUserId(String savedByUserId)
|
||||
{
|
||||
this.savedByUserId = savedByUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for savedByName
|
||||
*******************************************************************************/
|
||||
public String getSavedByName()
|
||||
{
|
||||
return (this.savedByName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for savedByName
|
||||
*******************************************************************************/
|
||||
public void setSavedByName(String savedByName)
|
||||
{
|
||||
this.savedByName = savedByName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for savedByName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withSavedByName(String savedByName)
|
||||
{
|
||||
this.savedByName = savedByName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for comment
|
||||
*******************************************************************************/
|
||||
public String getComment()
|
||||
{
|
||||
return (this.comment);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for comment
|
||||
*******************************************************************************/
|
||||
public void setComment(String comment)
|
||||
{
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for comment
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withComment(String comment)
|
||||
{
|
||||
this.comment = comment;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A result pinned to a notebook cell: the columns and (capped) rows of one
|
||||
** execution.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchResultSnapshot extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sql_workbench_result_snapshot";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchResultSnapshot";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String cellKey;
|
||||
|
||||
@QField(isEditable = false, maxLength = 36)
|
||||
private String executionUuid;
|
||||
|
||||
@QField(maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String label;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String backendName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String sqlText;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String resultJson;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer rowCount;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Boolean truncated;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String createdByUserId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellKey
|
||||
*******************************************************************************/
|
||||
public String getCellKey()
|
||||
{
|
||||
return (this.cellKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellKey
|
||||
*******************************************************************************/
|
||||
public void setCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellKey
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for executionUuid
|
||||
*******************************************************************************/
|
||||
public String getExecutionUuid()
|
||||
{
|
||||
return (this.executionUuid);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public void setExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (this.backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sqlText
|
||||
*******************************************************************************/
|
||||
public String getSqlText()
|
||||
{
|
||||
return (this.sqlText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sqlText
|
||||
*******************************************************************************/
|
||||
public void setSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sqlText
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resultJson
|
||||
*******************************************************************************/
|
||||
public String getResultJson()
|
||||
{
|
||||
return (this.resultJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for resultJson
|
||||
*******************************************************************************/
|
||||
public void setResultJson(String resultJson)
|
||||
{
|
||||
this.resultJson = resultJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resultJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withResultJson(String resultJson)
|
||||
{
|
||||
this.resultJson = resultJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowCount
|
||||
*******************************************************************************/
|
||||
public Integer getRowCount()
|
||||
{
|
||||
return (this.rowCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rowCount
|
||||
*******************************************************************************/
|
||||
public void setRowCount(Integer rowCount)
|
||||
{
|
||||
this.rowCount = rowCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowCount
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withRowCount(Integer rowCount)
|
||||
{
|
||||
this.rowCount = rowCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated
|
||||
*******************************************************************************/
|
||||
public Boolean getTruncated()
|
||||
{
|
||||
return (this.truncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for truncated
|
||||
*******************************************************************************/
|
||||
public void setTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for truncated
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public String getCreatedByUserId()
|
||||
{
|
||||
return (this.createdByUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public void setCreatedByUserId(String createdByUserId)
|
||||
{
|
||||
this.createdByUserId = createdByUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withCreatedByUserId(String createdByUserId)
|
||||
{
|
||||
this.createdByUserId = createdByUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.notebook;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
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.UUID;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.customizers.SqlWorkbenchActionFlag;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.CollectedResult;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebooks, their cells, versions, pinned result snapshots and shares, for
|
||||
** the notebook UI.
|
||||
**
|
||||
** Everything here runs as the caller's own session through the standard
|
||||
** QQQ table actions, so the same record locks and table customizers apply
|
||||
** as for the table UI and the /qqq/v1 table API: a caller reads their own
|
||||
** notebooks and the ones shared with them (a user / group share, or the
|
||||
** shared library), and only the owner changes one. The explicit checks
|
||||
** (readable / owned / canShare) stay as defense in depth and to give the
|
||||
** notebook UI its 404 / 403 answers. Only version bookkeeping runs as the
|
||||
** system user (NotebookVersioning), because versions are append-only.
|
||||
**
|
||||
** Saves replace a notebook's cells and append one version (full JSON), in
|
||||
** one transaction; versions beyond notebookVersionRetention are pruned.
|
||||
*******************************************************************************/
|
||||
public class NotebookService
|
||||
{
|
||||
public static final String VISIBILITY_PERSONAL = "PERSONAL";
|
||||
public static final String VISIBILITY_SHARED = "SHARED";
|
||||
public static final String CELL_SQL = "SQL";
|
||||
public static final String CELL_MARKDOWN = "MARKDOWN";
|
||||
public static final int MAX_CELLS = 200;
|
||||
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public NotebookService(SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.config = runtime.getConfig();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String table(String name)
|
||||
{
|
||||
return (config.applyPrefix(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make sure the session carries the workbench keys (hosts normally apply
|
||||
** them at login, with groups).
|
||||
*******************************************************************************/
|
||||
private void prepare()
|
||||
{
|
||||
SqlWorkbenchSecurity.ensureSessionKeys(config, QContext.getQSession());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook summaries: scope "mine" (default) or "shared" (shared with the
|
||||
** caller - directly, through a group, or in the library - and not theirs).
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> list(String scope, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
QQueryFilter filter;
|
||||
if("shared".equalsIgnoreCase(scope))
|
||||
{
|
||||
Set<Serializable> ids = sharedWithCallerNotebookIds();
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return (new ArrayList<>());
|
||||
}
|
||||
filter = new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<>(ids)))
|
||||
.withCriteria(new QFilterCriteria("ownerUserId", QCriteriaOperator.NOT_EQUALS, caller.userId()));
|
||||
}
|
||||
else
|
||||
{
|
||||
filter = new QQueryFilter(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, caller.userId()));
|
||||
}
|
||||
filter.withOrderBy(new QFilterOrderBy("modifyDate", false)).withLimit(500);
|
||||
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), filter))
|
||||
{
|
||||
result.add(summary(new SqlWorkbenchNotebook(record), caller));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One notebook with its cells.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> get(Integer id, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
SqlWorkbenchNotebook notebook = readable(id, caller);
|
||||
Map<String, Object> view = summary(notebook, caller);
|
||||
view.put("parameters", parseParameters(notebook.getParametersJson()));
|
||||
view.put("cells", NotebookVersioning.cells(config, notebook.getId(), null).stream().map(NotebookService::cellView).toList());
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a personal notebook (version 1). The table customizer makes the
|
||||
** caller its owner.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> create(Map<String, Object> body, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook();
|
||||
applyFields(notebook, body, true);
|
||||
List<SqlWorkbenchCell> cells = parseCells(body.get("cells"));
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE)
|
||||
.withRecordEntity(notebook)
|
||||
.withTransaction(transaction));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
notebook.setId(output.getRecords().get(0).getValueInteger("id"));
|
||||
saveContent(notebook, cells, (String) body.get("comment"), transaction);
|
||||
commit(transaction);
|
||||
}
|
||||
return (get(notebook.getId(), caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save a notebook: fields, cells (replaced), and a new version.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> update(Integer id, Map<String, Object> body, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
SqlWorkbenchNotebook notebook = owned(id, caller);
|
||||
applyFields(notebook, body, false);
|
||||
List<SqlWorkbenchCell> cells = body.containsKey("cells") ? parseCells(body.get("cells")) : NotebookVersioning.cells(config, id, null);
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
saveContent(notebook, cells, (String) body.get("comment"), transaction);
|
||||
commit(transaction);
|
||||
}
|
||||
return (get(id, caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a notebook; its table customizer deletes everything under it.
|
||||
*******************************************************************************/
|
||||
public void delete(Integer id, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
owned(id, caller);
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withPrimaryKeys(List.of(id)).withTransaction(transaction));
|
||||
throwIfErrors(output.getRecordsWithErrors());
|
||||
commit(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish to (or withdraw from) the shared library: an ALL share row.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> publish(Integer id, boolean shared, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
if(!caller.canShare())
|
||||
{
|
||||
throw (new QPermissionDeniedException("Publishing to the shared library needs the " + config.getSharePermissionName() + " permission."));
|
||||
}
|
||||
owned(id, caller);
|
||||
|
||||
QQueryFilter libraryShare = byNotebook(id).withCriteria(new QFilterCriteria("audienceKey", QCriteriaOperator.EQUALS, SqlWorkbenchSecurity.AUDIENCE_ALL));
|
||||
boolean published = !QueryAction.execute(table(SqlWorkbenchNotebookShare.TABLE_NAME), libraryShare).isEmpty();
|
||||
if(shared && !published)
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebookShare.TABLE_NAME)).withRecordEntity(new SqlWorkbenchNotebookShare()
|
||||
.withNotebookId(id)
|
||||
.withAudienceType(ShareAudienceType.ALL.name())));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
}
|
||||
else if(!shared && published)
|
||||
{
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebookShare.TABLE_NAME)).withQueryFilter(libraryShare));
|
||||
throwIfErrors(output.getRecordsWithErrors());
|
||||
}
|
||||
return (get(id, caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A personal copy of a readable notebook.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> duplicate(Integer id, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
SqlWorkbenchNotebook source = readable(id, caller);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("title", truncate("Copy of " + source.getTitle(), 255));
|
||||
body.put("description", source.getDescription());
|
||||
body.put("defaultBackendName", source.getDefaultBackendName());
|
||||
body.put("parameters", parseParameters(source.getParametersJson()));
|
||||
List<Map<String, Object>> cells = new ArrayList<>();
|
||||
for(SqlWorkbenchCell cell : NotebookVersioning.cells(config, id, null))
|
||||
{
|
||||
Map<String, Object> view = cellView(cell);
|
||||
view.put("cellKey", UUID.randomUUID().toString());
|
||||
cells.add(view);
|
||||
}
|
||||
body.put("cells", cells);
|
||||
body.put("comment", "Duplicated from notebook " + id);
|
||||
return (create(body, caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version list, newest first.
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> versions(Integer id, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
readable(id, caller);
|
||||
QQueryFilter filter = byNotebook(id).withOrderBy(new QFilterOrderBy("versionNumber", false));
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter))
|
||||
{
|
||||
SqlWorkbenchNotebookVersion version = new SqlWorkbenchNotebookVersion(record);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("versionNumber", version.getVersionNumber());
|
||||
view.put("savedByName", version.getSavedByName());
|
||||
view.put("comment", version.getComment());
|
||||
view.put("createDate", String.valueOf(version.getCreateDate()));
|
||||
result.add(view);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One version's content.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> version(Integer id, Integer versionNumber, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
readable(id, caller);
|
||||
SqlWorkbenchNotebookVersion version = findVersion(id, versionNumber);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("versionNumber", version.getVersionNumber());
|
||||
view.put("savedByName", version.getSavedByName());
|
||||
view.put("comment", version.getComment());
|
||||
view.put("createDate", String.valueOf(version.getCreateDate()));
|
||||
view.put("notebook", parseJsonObject(version.getContentJson()));
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore a version (saved as a new version).
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> restore(Integer id, Integer versionNumber, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
owned(id, caller);
|
||||
Map<String, Object> content = parseJsonObject(findVersion(id, versionNumber).getContentJson());
|
||||
Map<String, Object> body = new LinkedHashMap<>(content);
|
||||
body.put("comment", "Restored version " + versionNumber);
|
||||
return (update(id, body, caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pinned snapshots of a notebook.
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> snapshots(Integer id, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
readable(id, caller);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for(QRecord record : QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), byNotebook(id).withOrderBy(new QFilterOrderBy("id", false))))
|
||||
{
|
||||
result.add(snapshotView(new SqlWorkbenchResultSnapshot(record)));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin a cached result of the caller to a notebook cell.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> createSnapshot(Integer id, Map<String, Object> body, Caller caller, String sqlText) throws QException
|
||||
{
|
||||
prepare();
|
||||
owned(id, caller);
|
||||
String executionUuid = (String) body.get("executionUuid");
|
||||
ResultCache.Entry entry = runtime.getResultCache().get(executionUuid, caller.userId())
|
||||
.orElseThrow(() -> new QNotFoundException("That result is no longer available; run the cell again, then pin it."));
|
||||
CollectedResult result = entry.result();
|
||||
int max = config.getSnapshotMaxRows();
|
||||
List<List<Object>> rows = result.rows().size() > max ? result.rows().subList(0, max) : result.rows();
|
||||
Map<String, Object> json = new LinkedHashMap<>();
|
||||
json.put("columns", result.columns().stream().map(ColumnInfo::toMap).toList());
|
||||
json.put("rows", rows);
|
||||
SqlWorkbenchResultSnapshot snapshot = new SqlWorkbenchResultSnapshot()
|
||||
.withNotebookId(id)
|
||||
.withCellKey((String) body.get("cellKey"))
|
||||
.withExecutionUuid(executionUuid)
|
||||
.withLabel(truncate((String) body.get("label"), 255))
|
||||
.withBackendName(entry.backendName())
|
||||
.withSqlText(sqlText)
|
||||
.withResultJson(JsonUtils.toJson(json))
|
||||
.withRowCount(rows.size())
|
||||
.withTruncated(result.truncated() || result.rows().size() > max)
|
||||
.withCreatedByUserId(caller.userId())
|
||||
.withCreateDate(runtime.getClock().instant());
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.SNAPSHOT_PIN)
|
||||
.withRecordEntity(snapshot));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
snapshot.setId(output.getRecords().get(0).getValueInteger("id"));
|
||||
return (snapshotView(snapshot));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unpin.
|
||||
*******************************************************************************/
|
||||
public void deleteSnapshot(Integer id, Integer snapshotId, Caller caller) throws QException
|
||||
{
|
||||
prepare();
|
||||
owned(id, caller);
|
||||
QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("id", QCriteriaOperator.EQUALS, snapshotId));
|
||||
if(QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), filter).isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Snapshot not found."));
|
||||
}
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)).withPrimaryKeys(List.of(snapshotId)));
|
||||
throwIfErrors(output.getRecordsWithErrors());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook the caller may read, or 404 (never 403: notebooks not shared
|
||||
** with the caller do not exist for them). The record locks already hide
|
||||
** them; the owner / share check repeats that decision explicitly.
|
||||
*******************************************************************************/
|
||||
SqlWorkbenchNotebook readable(Integer id, Caller caller) throws QException
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = find(id);
|
||||
if(notebook == null)
|
||||
{
|
||||
throw (new QNotFoundException("Notebook not found."));
|
||||
}
|
||||
boolean owner = Objects.equals(notebook.getOwnerUserId(), caller.userId());
|
||||
if(!owner && !SqlWorkbenchSecurity.hasAllAccess(config) && !sharedWithCallerNotebookIds().contains(notebook.getId()))
|
||||
{
|
||||
throw (new QNotFoundException("Notebook not found."));
|
||||
}
|
||||
return (notebook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook the caller owns: 404 if unreadable, 403 if readable but not
|
||||
** theirs.
|
||||
*******************************************************************************/
|
||||
SqlWorkbenchNotebook owned(Integer id, Caller caller) throws QException
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = readable(id, caller);
|
||||
if(!Objects.equals(notebook.getOwnerUserId(), caller.userId()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Only the notebook's owner can change it; duplicate it to make your own copy."));
|
||||
}
|
||||
return (notebook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notebook, as the caller's session can see it (or null).
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchNotebook find(Integer id) throws QException
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
throw (new QBadRequestException("A notebook id is required."));
|
||||
}
|
||||
List<QRecord> records = QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id)));
|
||||
return (records.isEmpty() ? null : new SqlWorkbenchNotebook(records.get(0)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ids of the notebooks shared with one of the session's audiences.
|
||||
*******************************************************************************/
|
||||
private Set<Serializable> sharedWithCallerNotebookIds() throws QException
|
||||
{
|
||||
Set<Serializable> ids = new LinkedHashSet<>();
|
||||
List<String> audienceKeys = SqlWorkbenchSecurity.sessionAudienceKeys(config);
|
||||
if(audienceKeys.isEmpty())
|
||||
{
|
||||
return (ids);
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("audienceKey", QCriteriaOperator.IN, new ArrayList<>(audienceKeys)));
|
||||
for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebookShare.TABLE_NAME), filter))
|
||||
{
|
||||
ids.add(record.getValueInteger("notebookId"));
|
||||
}
|
||||
return (ids);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchNotebookVersion findVersion(Integer id, Integer versionNumber) throws QException
|
||||
{
|
||||
QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("versionNumber", QCriteriaOperator.EQUALS, versionNumber));
|
||||
List<QRecord> records = QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter);
|
||||
if(records.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Version not found."));
|
||||
}
|
||||
return (new SqlWorkbenchNotebookVersion(records.get(0)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter byNotebook(Integer id)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, id)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write the notebook's fields, replace its cells, append a version - all
|
||||
** in the given transaction, as the caller (NOTEBOOK_SAVE tells the table
|
||||
** customizers this is one save, versioned once at the end).
|
||||
*******************************************************************************/
|
||||
private void saveContent(SqlWorkbenchNotebook notebook, List<SqlWorkbenchCell> cells, String comment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
UpdateOutput updated = new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE)
|
||||
.withTransaction(transaction)
|
||||
.withRecord(new QRecord()
|
||||
.withValue("id", notebook.getId())
|
||||
.withValue("title", notebook.getTitle())
|
||||
.withValue("description", notebook.getDescription())
|
||||
.withValue("defaultBackendName", notebook.getDefaultBackendName())
|
||||
.withValue("parametersJson", notebook.getParametersJson())));
|
||||
throwIfErrors(updated.getRecords().get(0));
|
||||
|
||||
DeleteOutput deleted = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchCell.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE)
|
||||
.withQueryFilter(byNotebook(notebook.getId()))
|
||||
.withTransaction(transaction));
|
||||
throwIfErrors(deleted.getRecordsWithErrors());
|
||||
|
||||
for(int i = 0; i < cells.size(); i++)
|
||||
{
|
||||
cells.get(i).withId(null).withNotebookId(notebook.getId()).withSortOrder(i);
|
||||
}
|
||||
if(!cells.isEmpty())
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchCell.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE)
|
||||
.withRecordEntities(cells)
|
||||
.withTransaction(transaction));
|
||||
for(QRecord record : output.getRecords())
|
||||
{
|
||||
throwIfErrors(record);
|
||||
}
|
||||
}
|
||||
|
||||
NotebookVersioning.appendVersion(config, runtime.getClock(), notebook.getId(), comment, transaction);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply title / description / defaultBackendName / parameters from a body.
|
||||
*******************************************************************************/
|
||||
private void applyFields(SqlWorkbenchNotebook notebook, Map<String, Object> body, boolean creating) throws QException
|
||||
{
|
||||
if(creating || body.containsKey("title"))
|
||||
{
|
||||
String title = body.get("title") == null ? null : body.get("title").toString().trim();
|
||||
if(!StringUtils.hasContent(title))
|
||||
{
|
||||
throw (new QBadRequestException("A notebook needs a title."));
|
||||
}
|
||||
notebook.setTitle(truncate(title, 255));
|
||||
}
|
||||
if(body.containsKey("description"))
|
||||
{
|
||||
notebook.setDescription(body.get("description") == null ? null : body.get("description").toString());
|
||||
}
|
||||
if(body.containsKey("defaultBackendName"))
|
||||
{
|
||||
notebook.setDefaultBackendName(body.get("defaultBackendName") == null ? null : body.get("defaultBackendName").toString());
|
||||
}
|
||||
if(body.containsKey("parameters"))
|
||||
{
|
||||
notebook.setParametersJson(body.get("parameters") == null ? null : JsonUtils.toJson(body.get("parameters")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cells from a body: [{cellKey, cellType, title, content, backendName}].
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
List<SqlWorkbenchCell> parseCells(Object raw) throws QException
|
||||
{
|
||||
List<SqlWorkbenchCell> cells = new ArrayList<>();
|
||||
if(raw == null)
|
||||
{
|
||||
return (cells);
|
||||
}
|
||||
if(!(raw instanceof List<?> list))
|
||||
{
|
||||
throw (new QBadRequestException("cells must be a list."));
|
||||
}
|
||||
if(list.size() > MAX_CELLS)
|
||||
{
|
||||
throw (new QBadRequestException("A notebook can have at most " + MAX_CELLS + " cells."));
|
||||
}
|
||||
|
||||
Set<String> keys = new HashSet<>();
|
||||
for(Object item : list)
|
||||
{
|
||||
if(!(item instanceof Map<?, ?> map))
|
||||
{
|
||||
throw (new QBadRequestException("Each cell must be an object."));
|
||||
}
|
||||
Map<String, Object> cell = (Map<String, Object>) map;
|
||||
String type = cell.get("cellType") == null ? CELL_SQL : cell.get("cellType").toString().toUpperCase();
|
||||
if(!CELL_SQL.equals(type) && !CELL_MARKDOWN.equals(type))
|
||||
{
|
||||
throw (new QBadRequestException("cellType must be SQL or MARKDOWN."));
|
||||
}
|
||||
String content = cell.get("content") == null ? "" : cell.get("content").toString();
|
||||
if(content.length() > config.getMaxSqlLength())
|
||||
{
|
||||
throw (new QBadRequestException("A cell is longer than the limit of " + config.getMaxSqlLength() + " characters."));
|
||||
}
|
||||
String key = cell.get("cellKey") == null ? null : cell.get("cellKey").toString();
|
||||
if(!StringUtils.hasContent(key) || key.length() > 64 || !keys.add(key))
|
||||
{
|
||||
key = UUID.randomUUID().toString();
|
||||
keys.add(key);
|
||||
}
|
||||
cells.add(new SqlWorkbenchCell()
|
||||
.withCellKey(key)
|
||||
.withCellType(type)
|
||||
.withTitle(truncate(cell.get("title") == null ? null : cell.get("title").toString(), 255))
|
||||
.withContent(content)
|
||||
.withBackendName(cell.get("backendName") == null ? null : cell.get("backendName").toString()));
|
||||
}
|
||||
return (cells);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> summary(SqlWorkbenchNotebook notebook, Caller caller)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("id", notebook.getId());
|
||||
view.put("title", notebook.getTitle());
|
||||
view.put("description", notebook.getDescription());
|
||||
view.put("visibility", notebook.getVisibility());
|
||||
view.put("ownerUserId", notebook.getOwnerUserId());
|
||||
view.put("ownerName", notebook.getOwnerName());
|
||||
view.put("defaultBackendName", notebook.getDefaultBackendName());
|
||||
view.put("currentVersion", notebook.getCurrentVersion());
|
||||
view.put("canEdit", Objects.equals(notebook.getOwnerUserId(), caller.userId()));
|
||||
view.put("createDate", notebook.getCreateDate() == null ? null : notebook.getCreateDate().toString());
|
||||
view.put("modifyDate", notebook.getModifyDate() == null ? null : notebook.getModifyDate().toString());
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> cellView(SqlWorkbenchCell cell)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("cellKey", cell.getCellKey());
|
||||
view.put("cellType", cell.getCellType());
|
||||
view.put("title", cell.getTitle());
|
||||
view.put("content", cell.getContent());
|
||||
view.put("backendName", cell.getBackendName());
|
||||
view.put("sortOrder", cell.getSortOrder());
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> snapshotView(SqlWorkbenchResultSnapshot snapshot)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("id", snapshot.getId());
|
||||
view.put("cellKey", snapshot.getCellKey());
|
||||
view.put("executionUuid", snapshot.getExecutionUuid());
|
||||
view.put("label", snapshot.getLabel());
|
||||
view.put("backendName", snapshot.getBackendName());
|
||||
view.put("rowCount", snapshot.getRowCount());
|
||||
view.put("truncated", snapshot.getTruncated());
|
||||
view.put("createDate", snapshot.getCreateDate() == null ? null : snapshot.getCreateDate().toString());
|
||||
view.put("result", parseJsonObject(snapshot.getResultJson()));
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static List<Object> parseParameters(String json)
|
||||
{
|
||||
if(!StringUtils.hasContent(json))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (JsonUtils.toObject(json, new TypeReference<List<Object>>() {}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> parseJsonObject(String json)
|
||||
{
|
||||
if(!StringUtils.hasContent(json))
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (JsonUtils.toObject(json, new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Throw the first error of a record (a QPermissionDeniedException for
|
||||
** permission errors, so the notebook UI answers 403).
|
||||
*******************************************************************************/
|
||||
static void throwIfErrors(QRecord record) throws QException
|
||||
{
|
||||
if(record != null && CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
String message = record.getErrors().get(0).getMessage();
|
||||
if(record.getErrors().get(0) instanceof com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage)
|
||||
{
|
||||
throw (new QPermissionDeniedException(message));
|
||||
}
|
||||
throw (new QBadRequestException("Could not save: " + message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Throw the first error of any record.
|
||||
*******************************************************************************/
|
||||
static void throwIfErrors(List<QRecord> records) throws QException
|
||||
{
|
||||
for(QRecord record : CollectionUtils.nonNullList(records))
|
||||
{
|
||||
throwIfErrors(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit, when the backend gave a transaction.
|
||||
*******************************************************************************/
|
||||
private static void commit(QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(transaction != null)
|
||||
{
|
||||
transaction.commit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String truncate(String value, int max)
|
||||
{
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max - 1) + "…");
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.notebook;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.customizers.SqlWorkbenchActionFlag;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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.QException;
|
||||
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.insert.InsertOutput;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook versions: every change to a notebook's content (from the
|
||||
** notebook UI or from the table UI / API) appends one immutable version
|
||||
** holding the whole notebook, and bumps the notebook's currentVersion.
|
||||
**
|
||||
** The caller's change has already passed the record locks (only the owner,
|
||||
** or an admin, gets here); the bookkeeping itself is written as the system
|
||||
** user, because versions are append-only for everyone else.
|
||||
*******************************************************************************/
|
||||
public final class NotebookVersioning
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private NotebookVersioning()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append a version of the notebook as stored now (in the transaction, if
|
||||
** any), saved by the current session's user. Returns the new version
|
||||
** number, or null if the notebook does not exist.
|
||||
*******************************************************************************/
|
||||
public static Integer appendVersion(SqlWorkbenchQBitConfig config, Clock clock, Integer notebookId, String comment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
String userId = SqlWorkbenchSecurity.userId(session);
|
||||
String userName = SqlWorkbenchSecurity.userName(session);
|
||||
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
List<QRecord> found = new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME))
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, notebookId)))
|
||||
.withTransaction(transaction)).getRecords();
|
||||
if(found.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook(found.get(0));
|
||||
Instant now = clock.instant();
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// next number: past both currentVersion and the newest stored //
|
||||
// version, so a stale currentVersion can never wedge later saves //
|
||||
// on the (notebookId, versionNumber) unique key //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
int latest = notebook.getCurrentVersion() == null ? 0 : notebook.getCurrentVersion();
|
||||
List<QRecord> newest = new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME))
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)).withOrderBy(new QFilterOrderBy("versionNumber", false)).withLimit(1))
|
||||
.withTransaction(transaction)).getRecords();
|
||||
if(!newest.isEmpty() && newest.get(0).getValueInteger("versionNumber") != null)
|
||||
{
|
||||
latest = Math.max(latest, newest.get(0).getValueInteger("versionNumber"));
|
||||
}
|
||||
int version = latest + 1;
|
||||
|
||||
UpdateOutput updated = new UpdateAction().execute(new UpdateInput(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.INTERNAL)
|
||||
.withTransaction(transaction)
|
||||
.withRecord(new QRecord().withValue("id", notebookId).withValue("currentVersion", version).withValue("modifyDate", now)));
|
||||
NotebookService.throwIfErrors(updated.getRecords().get(0));
|
||||
|
||||
SqlWorkbenchNotebookVersion versionRow = new SqlWorkbenchNotebookVersion()
|
||||
.withNotebookId(notebookId)
|
||||
.withVersionNumber(version)
|
||||
.withContentJson(JsonUtils.toJson(content(notebook, cells(config, notebookId, transaction))))
|
||||
.withSavedByUserId(userId)
|
||||
.withSavedByName(userName)
|
||||
.withComment(NotebookService.truncate(comment, 255))
|
||||
.withCreateDate(now);
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.INTERNAL)
|
||||
.withTransaction(transaction)
|
||||
.withRecordEntity(versionRow));
|
||||
NotebookService.throwIfErrors(output.getRecords().get(0));
|
||||
|
||||
prune(config, notebookId, transaction);
|
||||
return (version);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook's cells, in order (as the current session).
|
||||
*******************************************************************************/
|
||||
static List<SqlWorkbenchCell> cells(SqlWorkbenchQBitConfig config, Integer notebookId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId))
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder"))
|
||||
.withOrderBy(new QFilterOrderBy("id"));
|
||||
List<SqlWorkbenchCell> cells = new ArrayList<>();
|
||||
for(QRecord record : new QueryAction().execute(new QueryInput(config.applyPrefix(SqlWorkbenchCell.TABLE_NAME)).withFilter(filter).withTransaction(transaction)).getRecords())
|
||||
{
|
||||
cells.add(new SqlWorkbenchCell(record));
|
||||
}
|
||||
return (cells);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The content a version holds.
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> content(SqlWorkbenchNotebook notebook, List<SqlWorkbenchCell> cells)
|
||||
{
|
||||
Map<String, Object> content = new LinkedHashMap<>();
|
||||
content.put("title", notebook.getTitle());
|
||||
content.put("description", notebook.getDescription());
|
||||
content.put("defaultBackendName", notebook.getDefaultBackendName());
|
||||
content.put("parameters", NotebookService.parseParameters(notebook.getParametersJson()));
|
||||
content.put("cells", cells.stream().map(NotebookService::cellView).toList());
|
||||
return (content);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete versions beyond the retention, oldest first (system user).
|
||||
*******************************************************************************/
|
||||
private static void prune(SqlWorkbenchQBitConfig config, Integer notebookId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME))
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId)).withOrderBy(new QFilterOrderBy("versionNumber", false)))
|
||||
.withTransaction(transaction);
|
||||
List<QRecord> records = new QueryAction().execute(input).getRecords();
|
||||
if(records.size() <= config.getNotebookVersionRetention())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<Serializable> stale = new ArrayList<>();
|
||||
for(QRecord record : records.subList(config.getNotebookVersionRetention(), records.size()))
|
||||
{
|
||||
stale.add(record.getValueInteger("id"));
|
||||
}
|
||||
new DeleteAction().execute(new DeleteInput(config.applyPrefix(SqlWorkbenchNotebookVersion.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.INTERNAL)
|
||||
.withTransaction(transaction)
|
||||
.withPrimaryKeys(stale));
|
||||
}
|
||||
}
|
||||
-163
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.processes;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchSecurity;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend steps of the workbench's record actions. QQQ loads the
|
||||
** selected records through the table's record locks (as the session), so a
|
||||
** step only ever sees rows the user can read; the writes then go through
|
||||
** the same table actions and customizers as everything else.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchRecordActionSteps
|
||||
{
|
||||
public static final String RESULT_MESSAGE = "resultMessage";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchRecordActionSteps()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The single selected record (min/max input records are 1).
|
||||
*******************************************************************************/
|
||||
static QRecord selected(RunBackendStepInput input) throws QException
|
||||
{
|
||||
if(CollectionUtils.nullSafeIsEmpty(input.getRecords()))
|
||||
{
|
||||
throw (new QUserFacingException("The selected record was not found."));
|
||||
}
|
||||
return (input.getRecords().get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static NotebookService notebooks() throws QException
|
||||
{
|
||||
return (new NotebookService(SqlWorkbenchRuntime.get()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Caller caller() throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = SqlWorkbenchRuntime.get().getConfig();
|
||||
SqlWorkbenchSecurity.ensureSessionKeys(config, QContext.getQSession());
|
||||
return (Caller.fromSession(config, QContext.getQSession(), null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share the selected notebook with a user, a group, or everyone (the
|
||||
** shared library): inserts a share row as the session.
|
||||
*******************************************************************************/
|
||||
public static class ShareNotebookStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
QRecord notebook = selected(input);
|
||||
SqlWorkbenchQBitConfig config = SqlWorkbenchRuntime.get().getConfig();
|
||||
InsertOutput inserted = new InsertAction().execute(new InsertInput(config.applyPrefix(SqlWorkbenchNotebookShare.TABLE_NAME)).withRecordEntity(new SqlWorkbenchNotebookShare()
|
||||
.withNotebookId(notebook.getValueInteger("id"))
|
||||
.withAudienceType(input.getValueString("audienceType"))
|
||||
.withAudienceId(input.getValueString("audienceId"))));
|
||||
QRecord share = inserted.getRecords().get(0);
|
||||
if(CollectionUtils.nullSafeHasContents(share.getErrors()))
|
||||
{
|
||||
throw (new QUserFacingException(share.getErrors().get(0).getMessage()));
|
||||
}
|
||||
output.addValue(RESULT_MESSAGE, "Shared \"" + notebook.getValueString("title") + "\" (" + share.getValueString("audienceKey") + ").");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy the selected (readable) notebook into a new notebook the session
|
||||
** owns.
|
||||
*******************************************************************************/
|
||||
public static class DuplicateNotebookStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
QRecord notebook = selected(input);
|
||||
Map<String, Object> copy = notebooks().duplicate(notebook.getValueInteger("id"), caller());
|
||||
output.addValue(RESULT_MESSAGE, "Created \"" + copy.get("title") + "\" (notebook " + copy.get("id") + ").");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore the selected version of a notebook the session owns (saved as
|
||||
** a new version).
|
||||
*******************************************************************************/
|
||||
public static class RestoreVersionStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
QRecord version = selected(input);
|
||||
Map<String, Object> restored = notebooks().restore(version.getValueInteger("notebookId"), version.getValueInteger("versionNumber"), caller());
|
||||
output.addValue(RESULT_MESSAGE, "Restored version " + version.getValueInteger("versionNumber") + " as version " + restored.get("currentVersion") + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.schema;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describes a target backend for autocomplete: schemas, tables, columns
|
||||
** (JDBC DatabaseMetaData, system schemas excluded, capped at
|
||||
** maxSchemaTables), keywords and functions, plus the instance's QQQ tables
|
||||
** on that backend with their SQL table and column names. Cached per
|
||||
** backend in the runtime.
|
||||
*******************************************************************************/
|
||||
public class SchemaIntrospector
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SchemaIntrospector.class);
|
||||
|
||||
private static final Set<String> SYSTEM_SCHEMAS = Set.of("pg_catalog", "information_schema", "pg_toast", "mysql", "performance_schema", "sys");
|
||||
|
||||
private static final List<String> CORE_KEYWORDS = List.of("SELECT", "FROM", "WHERE", "GROUP BY", "HAVING", "ORDER BY", "LIMIT", "OFFSET", "JOIN",
|
||||
"LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL OUTER JOIN", "CROSS JOIN", "ON", "USING", "AS", "DISTINCT", "UNION", "UNION ALL", "INTERSECT", "EXCEPT",
|
||||
"WITH", "CASE", "WHEN", "THEN", "ELSE", "END", "AND", "OR", "NOT", "IN", "EXISTS", "BETWEEN", "LIKE", "IS NULL", "IS NOT NULL", "ASC", "DESC",
|
||||
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "RETURNING", "CREATE TABLE", "ALTER TABLE", "DROP TABLE", "EXPLAIN");
|
||||
|
||||
private static final List<String> CORE_FUNCTIONS = List.of("count", "sum", "avg", "min", "max", "coalesce", "nullif", "cast", "lower", "upper", "trim",
|
||||
"length", "substring", "replace", "concat", "round", "floor", "ceil", "abs", "now", "current_date", "current_timestamp");
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final WorkbenchBackends backends;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SchemaIntrospector(QInstance qInstance, SqlWorkbenchRuntime runtime, WorkbenchBackends backends)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.runtime = runtime;
|
||||
this.backends = backends;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The schema of a backend, from cache unless refresh or stale.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> describe(String backendName, boolean refresh) throws QException
|
||||
{
|
||||
WorkbenchBackends.Target target = backends.getTarget(backendName);
|
||||
if(!refresh)
|
||||
{
|
||||
SqlWorkbenchRuntime.CachedSchema cached = runtime.getCachedSchema(target.name());
|
||||
if(cached != null)
|
||||
{
|
||||
return (cached.schema());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("backendName", target.name());
|
||||
schema.put("dialect", target.dialect().getUiName());
|
||||
|
||||
try(Connection connection = backends.openConnection(target, true))
|
||||
{
|
||||
DatabaseMetaData metaData = connection.getMetaData();
|
||||
boolean[] truncated = new boolean[1];
|
||||
schema.put("schemas", readSchemas(metaData, truncated));
|
||||
schema.put("truncated", truncated[0]);
|
||||
schema.put("keywords", keywords(metaData));
|
||||
schema.put("functions", functions(metaData));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// driver messages can expose connection details: log them, //
|
||||
// answer generically //
|
||||
////////////////////////////////////////////////////////////////
|
||||
LOG.warn("SQL Workbench could not read a schema", e, logPair("backendName", target.name()));
|
||||
throw (new QException("Could not read the schema of backend " + target.name() + "; see the server log.", e));
|
||||
}
|
||||
|
||||
schema.put("qqqTables", qqqTables(target.name()));
|
||||
schema.put("cachedAt", runtime.getClock().instant().toString());
|
||||
runtime.putCachedSchema(target.name(), schema);
|
||||
return (schema);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Schemas, tables and columns. Schema-less databases (MySQL catalogs,
|
||||
** SQLite) report the catalog, or "main".
|
||||
*******************************************************************************/
|
||||
List<Map<String, Object>> readSchemas(DatabaseMetaData metaData, boolean[] truncated) throws SQLException
|
||||
{
|
||||
Map<String, Map<String, Map<String, Object>>> tablesBySchema = new LinkedHashMap<>();
|
||||
int count = 0;
|
||||
|
||||
try(ResultSet rs = metaData.getTables(null, null, "%", null))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
String type = String.valueOf(rs.getString("TABLE_TYPE")).toUpperCase(Locale.ROOT);
|
||||
if(!(type.equals("TABLE") || type.equals("VIEW") || type.equals("BASE TABLE") || type.equals("MATERIALIZED VIEW") || type.equals("PARTITIONED TABLE")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
String schemaName = schemaName(rs.getString("TABLE_SCHEM"), rs.getString("TABLE_CAT"));
|
||||
if(isSystemSchema(schemaName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(count >= runtime.getConfig().getMaxSchemaTables())
|
||||
{
|
||||
truncated[0] = true;
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
|
||||
Map<String, Object> table = new LinkedHashMap<>();
|
||||
table.put("name", rs.getString("TABLE_NAME"));
|
||||
table.put("type", type);
|
||||
table.put("columns", new ArrayList<Map<String, Object>>());
|
||||
tablesBySchema.computeIfAbsent(schemaName, s -> new LinkedHashMap<>()).put(rs.getString("TABLE_NAME"), table);
|
||||
}
|
||||
}
|
||||
|
||||
readColumns(metaData, tablesBySchema);
|
||||
|
||||
List<Map<String, Object>> schemas = new ArrayList<>();
|
||||
for(Map.Entry<String, Map<String, Map<String, Object>>> entry : tablesBySchema.entrySet())
|
||||
{
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("name", entry.getKey());
|
||||
schema.put("tables", new ArrayList<>(entry.getValue().values()));
|
||||
schemas.add(schema);
|
||||
}
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One getColumns call for everything, assigned to the tables kept above.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void readColumns(DatabaseMetaData metaData, Map<String, Map<String, Map<String, Object>>> tablesBySchema) throws SQLException
|
||||
{
|
||||
try(ResultSet rs = metaData.getColumns(null, null, "%", "%"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
Map<String, Map<String, Object>> tables = tablesBySchema.get(schemaName(rs.getString("TABLE_SCHEM"), rs.getString("TABLE_CAT")));
|
||||
Map<String, Object> table = tables == null ? null : tables.get(rs.getString("TABLE_NAME"));
|
||||
if(table == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> column = new LinkedHashMap<>();
|
||||
column.put("name", rs.getString("COLUMN_NAME"));
|
||||
column.put("typeName", rs.getString("TYPE_NAME"));
|
||||
column.put("nullable", rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls);
|
||||
((List<Map<String, Object>>) table.get("columns")).add(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String schemaName(String schema, String catalog)
|
||||
{
|
||||
if(StringUtils.hasContent(schema))
|
||||
{
|
||||
return (schema);
|
||||
}
|
||||
return (StringUtils.hasContent(catalog) ? catalog : "main");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static boolean isSystemSchema(String schemaName)
|
||||
{
|
||||
String lower = schemaName.toLowerCase(Locale.ROOT);
|
||||
return (SYSTEM_SCHEMAS.contains(lower) || lower.startsWith("pg_temp") || lower.startsWith("pg_toast_temp"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core keywords plus the driver's extra keywords.
|
||||
*******************************************************************************/
|
||||
static List<String> keywords(DatabaseMetaData metaData)
|
||||
{
|
||||
Set<String> keywords = new TreeSet<>(CORE_KEYWORDS);
|
||||
try
|
||||
{
|
||||
keywords.addAll(splitList(metaData.getSQLKeywords(), true));
|
||||
}
|
||||
catch(SQLException | RuntimeException e)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// optional: keep the core keywords //
|
||||
////////////////////////////////////////
|
||||
}
|
||||
return (new ArrayList<>(keywords));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core functions plus the driver's function lists.
|
||||
*******************************************************************************/
|
||||
static List<String> functions(DatabaseMetaData metaData)
|
||||
{
|
||||
Set<String> functions = new TreeSet<>(CORE_FUNCTIONS);
|
||||
try
|
||||
{
|
||||
functions.addAll(splitList(metaData.getNumericFunctions(), false));
|
||||
functions.addAll(splitList(metaData.getStringFunctions(), false));
|
||||
functions.addAll(splitList(metaData.getSystemFunctions(), false));
|
||||
functions.addAll(splitList(metaData.getTimeDateFunctions(), false));
|
||||
}
|
||||
catch(SQLException | RuntimeException e)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// optional: keep the core functions //
|
||||
////////////////////////////////////////
|
||||
}
|
||||
return (new ArrayList<>(functions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> splitList(String list, boolean upper)
|
||||
{
|
||||
if(!StringUtils.hasContent(list))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (Arrays.stream(list.split(",")).map(String::trim).filter(s -> !s.isEmpty())
|
||||
.map(s -> upper ? s.toUpperCase(Locale.ROOT) : s.toLowerCase(Locale.ROOT)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ tables stored on this backend, with their SQL names.
|
||||
*******************************************************************************/
|
||||
List<Map<String, Object>> qqqTables(String backendName)
|
||||
{
|
||||
List<Map<String, Object>> tables = new ArrayList<>();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(!backendName.equals(table.getBackendName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("name", table.getName());
|
||||
view.put("label", table.getLabel());
|
||||
view.put("sqlName", AbstractRDBMSAction.getTableName(table));
|
||||
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
Map<String, Object> fieldView = new LinkedHashMap<>();
|
||||
fieldView.put("name", field.getName());
|
||||
fieldView.put("label", field.getLabel());
|
||||
fieldView.put("sqlName", AbstractRDBMSAction.getColumnName(field));
|
||||
fieldView.put("type", field.getType() == null ? null : field.getType().name());
|
||||
fields.add(fieldView);
|
||||
}
|
||||
view.put("fields", fields);
|
||||
tables.add(view);
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A statement with its :name parameters rewritten to JDBC '?' markers, plus
|
||||
** the parameter name for each marker, in order. Values are always bound
|
||||
** through PreparedStatement, never spliced into the text.
|
||||
**
|
||||
** @param jdbcSql the statement with ? markers
|
||||
** @param parameterOrder the parameter name bound at each marker
|
||||
*******************************************************************************/
|
||||
public record NamedParameterSql(String jdbcSql, List<String> parameterOrder)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Rewrite a classified statement.
|
||||
*******************************************************************************/
|
||||
public static NamedParameterSql of(SqlStatement statement)
|
||||
{
|
||||
String text = statement.text();
|
||||
int base = statement.tokens().isEmpty() ? 0 : statement.tokens().get(0).start();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
List<String> order = new ArrayList<>();
|
||||
int at = 0;
|
||||
|
||||
for(SqlToken token : statement.tokens())
|
||||
{
|
||||
if(token.type() == SqlToken.Type.PARAMETER)
|
||||
{
|
||||
int start = token.start() - base;
|
||||
sb.append(text, at, start).append('?');
|
||||
at = token.end() - base;
|
||||
order.add(token.text());
|
||||
}
|
||||
}
|
||||
sb.append(text.substring(at));
|
||||
return (new NamedParameterSql(sb.toString(), List.copyOf(order)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Splits a SQL text into statements and classifies each one.
|
||||
**
|
||||
** Classification is defence in depth, not the security boundary: read-only
|
||||
** sessions also run inside a database READ ONLY transaction (and, when
|
||||
** configured, with read-only credentials). It is deliberately conservative:
|
||||
** anything it does not recognise as a read is a write, and a "read" that
|
||||
** mentions a mutating keyword anywhere (data-modifying CTEs, SELECT ... INTO,
|
||||
** SELECT ... FOR UPDATE) is a write.
|
||||
*******************************************************************************/
|
||||
public class SqlClassifier
|
||||
{
|
||||
private static final Set<String> READ_LEADERS = Set.of("SELECT", "WITH", "VALUES", "TABLE", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA");
|
||||
|
||||
private static final Set<String> MUTATING_WORDS = Set.of("INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "INTO", "TRUNCATE", "CREATE", "DROP", "ALTER",
|
||||
"GRANT", "REVOKE", "COPY", "CALL", "LOCK", "NEXTVAL", "SETVAL");
|
||||
|
||||
private static final Set<String> DDL_LEADERS = Set.of("CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", "COMMENT", "GRANT", "REVOKE", "REINDEX", "CLUSTER",
|
||||
"VACUUM", "ANALYZE", "OPTIMIZE", "REFRESH", "SECURITY");
|
||||
|
||||
private static final Set<String> FORBIDDEN_LEADERS = Set.of("BEGIN", "START", "COMMIT", "END", "ROLLBACK", "ABORT", "SAVEPOINT", "RELEASE", "SET", "RESET",
|
||||
"DISCARD", "LISTEN", "UNLISTEN", "NOTIFY", "PREPARE", "EXECUTE", "DEALLOCATE", "USE", "ATTACH", "DETACH", "LOAD", "SHUTDOWN", "RUNSCRIPT", "SCRIPT",
|
||||
"BACKUP", "CONNECT", "CHECKPOINT", "KILL", "FLUSH", "INSTALL", "UNINSTALL", "HANDLER", "IMPORT", "DO");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split and classify. Statements made only of comments are dropped.
|
||||
*******************************************************************************/
|
||||
public static List<SqlStatement> split(String sql, SqlDialect dialect)
|
||||
{
|
||||
String text = sql == null ? "" : sql;
|
||||
List<SqlToken> tokens = SqlScanner.scan(text, dialect);
|
||||
List<SqlStatement> result = new ArrayList<>();
|
||||
List<SqlToken> current = new ArrayList<>();
|
||||
|
||||
for(SqlToken token : tokens)
|
||||
{
|
||||
if(token.type() == SqlToken.Type.SEMICOLON)
|
||||
{
|
||||
addStatement(text, current, result);
|
||||
current = new ArrayList<>();
|
||||
}
|
||||
else
|
||||
{
|
||||
current.add(token);
|
||||
}
|
||||
}
|
||||
addStatement(text, current, result);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void addStatement(String text, List<SqlToken> tokens, List<SqlStatement> result)
|
||||
{
|
||||
if(tokens.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
String statementText = text.substring(tokens.get(0).start(), tokens.get(tokens.size() - 1).end());
|
||||
result.add(classify(statementText, List.copyOf(tokens)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classify one statement's tokens.
|
||||
*******************************************************************************/
|
||||
static SqlStatement classify(String text, List<SqlToken> tokens)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// MySQL executes /*! ... */ and /*+ ... */: fail closed, whatever //
|
||||
// the rest of the statement looks like //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
if(tokens.stream().anyMatch(t -> t.type() == SqlToken.Type.EXECUTABLE_COMMENT))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.FORBIDDEN, null,
|
||||
"MySQL executable comments (/*! ... */, /*M! ... */) and optimizer hints (/*+ ... */) run as SQL; remove them"));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// skip leading parens, as in "(SELECT ...) UNION ..." //
|
||||
////////////////////////////////////////////////////////
|
||||
int i = 0;
|
||||
while(i < tokens.size() && tokens.get(i).type() == SqlToken.Type.OPEN_PAREN)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
if(i >= tokens.size() || tokens.get(i).type() != SqlToken.Type.WORD)
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.WRITE, null, "does not start with a keyword"));
|
||||
}
|
||||
|
||||
String leader = tokens.get(i).keyword();
|
||||
|
||||
if(FORBIDDEN_LEADERS.contains(leader))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.FORBIDDEN, leader, leader + " controls the transaction or session, which the workbench manages"));
|
||||
}
|
||||
|
||||
if(DDL_LEADERS.contains(leader))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.DDL, leader, leader + " changes the schema"));
|
||||
}
|
||||
|
||||
if(!READ_LEADERS.contains(leader))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.WRITE, leader, leader + " is not a read-only statement"));
|
||||
}
|
||||
|
||||
if("PRAGMA".equals(leader))
|
||||
{
|
||||
boolean assigns = tokens.stream().anyMatch(t -> t.type() == SqlToken.Type.SYMBOL && "=".equals(t.text()));
|
||||
return (assigns
|
||||
? new SqlStatement(text, tokens, StatementKind.FORBIDDEN, leader, "PRAGMA assignments change connection state")
|
||||
: new SqlStatement(text, tokens, StatementKind.READ, leader, "read-only PRAGMA"));
|
||||
}
|
||||
|
||||
if("EXPLAIN".equals(leader) && isExplainAnalyze(tokens, i + 1))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.EXPLAIN_ANALYZE, leader, "EXPLAIN ANALYZE executes the statement"));
|
||||
}
|
||||
|
||||
for(int j = i + 1; j < tokens.size(); j++)
|
||||
{
|
||||
String word = tokens.get(j).keyword();
|
||||
if(word != null && MUTATING_WORDS.contains(word))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// "IS DISTINCT FROM"-style false positives are not a concern: //
|
||||
// none of these words is an operator. nextval/setval only count //
|
||||
// when called, i.e. followed by '('. //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
if(("NEXTVAL".equals(word) || "SETVAL".equals(word)) && !(j + 1 < tokens.size() && tokens.get(j + 1).type() == SqlToken.Type.OPEN_PAREN))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
StatementKind kind = Set.of("CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE").contains(word) ? StatementKind.DDL : StatementKind.WRITE;
|
||||
return (new SqlStatement(text, tokens, kind, leader, leader + " statement contains " + word));
|
||||
}
|
||||
}
|
||||
|
||||
return (new SqlStatement(text, tokens, StatementKind.READ, leader, "read-only " + leader));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** EXPLAIN ANALYZE ..., EXPLAIN (ANALYZE[, ...]) ..., EXPLAIN (... ANALYZE
|
||||
** true ...) - but not EXPLAIN (ANALYZE false) or EXPLAIN QUERY PLAN.
|
||||
*******************************************************************************/
|
||||
private static boolean isExplainAnalyze(List<SqlToken> tokens, int start)
|
||||
{
|
||||
if(start >= tokens.size())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
SqlToken next = tokens.get(start);
|
||||
if(next.isKeyword("ANALYZE") || next.isKeyword("ANALYSE"))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
if(next.type() == SqlToken.Type.OPEN_PAREN)
|
||||
{
|
||||
for(int j = start + 1; j < tokens.size() && tokens.get(j).type() != SqlToken.Type.CLOSE_PAREN; j++)
|
||||
{
|
||||
if(tokens.get(j).isKeyword("ANALYZE") || tokens.get(j).isKeyword("ANALYSE"))
|
||||
{
|
||||
SqlToken value = j + 1 < tokens.size() ? tokens.get(j + 1) : null;
|
||||
boolean off = value != null && (value.isKeyword("FALSE") || value.isKeyword("OFF") || "0".equals(value.text()));
|
||||
return (!off);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SQL dialects the workbench understands. The dialect drives lexing
|
||||
** (comment and quoting rules), read-only enforcement, EXPLAIN syntax, and
|
||||
** whether a DDL dry run can be rolled back.
|
||||
**
|
||||
** The UI receives the dialect's name and maps it to its editor and formatter
|
||||
** dialects (postgresql, mysql, sqlite, sql).
|
||||
*******************************************************************************/
|
||||
public enum SqlDialect
|
||||
{
|
||||
POSTGRES("postgresql", true, false, false, true, false),
|
||||
MYSQL("mysql", false, true, true, false, true),
|
||||
SQLITE("sqlite", true, false, false, false, true),
|
||||
H2("h2", false, false, false, false, false),
|
||||
GENERIC("sql", false, false, false, false, false);
|
||||
|
||||
private final String uiName;
|
||||
private final boolean transactionalDdl;
|
||||
private final boolean hashComments;
|
||||
private final boolean backslashEscapes;
|
||||
private final boolean dollarQuotes;
|
||||
private final boolean backtickIdentifiers;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
SqlDialect(String uiName, boolean transactionalDdl, boolean hashComments, boolean backslashEscapes, boolean dollarQuotes, boolean backtickIdentifiers)
|
||||
{
|
||||
this.uiName = uiName;
|
||||
this.transactionalDdl = transactionalDdl;
|
||||
this.hashComments = hashComments;
|
||||
this.backslashEscapes = backslashEscapes;
|
||||
this.dollarQuotes = dollarQuotes;
|
||||
this.backtickIdentifiers = backtickIdentifiers;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a QQQ RDBMS vendor string (postgres, aurora-postgres, mysql,
|
||||
** aurora-mysql, sqlite, h2) to a dialect; anything else is GENERIC.
|
||||
*******************************************************************************/
|
||||
public static SqlDialect fromVendor(String vendor)
|
||||
{
|
||||
if(vendor == null)
|
||||
{
|
||||
return (GENERIC);
|
||||
}
|
||||
|
||||
String v = vendor.toLowerCase(Locale.ROOT);
|
||||
if(v.contains("postgres"))
|
||||
{
|
||||
return (POSTGRES);
|
||||
}
|
||||
if(v.contains("mysql") || v.contains("mariadb"))
|
||||
{
|
||||
return (MYSQL);
|
||||
}
|
||||
if(v.contains("sqlite"))
|
||||
{
|
||||
return (SQLITE);
|
||||
}
|
||||
if(v.contains("h2"))
|
||||
{
|
||||
return (H2);
|
||||
}
|
||||
return (GENERIC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the UI uses to pick its editor and formatter dialect.
|
||||
*******************************************************************************/
|
||||
public String getUiName()
|
||||
{
|
||||
return (uiName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether DDL runs inside a transaction (and so can be dry-run and rolled
|
||||
** back). MySQL and H2 commit implicitly on DDL.
|
||||
*******************************************************************************/
|
||||
public boolean isTransactionalDdl()
|
||||
{
|
||||
return (transactionalDdl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether '#' starts a line comment.
|
||||
*******************************************************************************/
|
||||
public boolean isHashComments()
|
||||
{
|
||||
return (hashComments);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether backslash escapes the next character inside string literals.
|
||||
*******************************************************************************/
|
||||
public boolean isBackslashEscapes()
|
||||
{
|
||||
return (backslashEscapes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether $tag$...$tag$ dollar-quoted strings exist.
|
||||
*******************************************************************************/
|
||||
public boolean isDollarQuotes()
|
||||
{
|
||||
return (dollarQuotes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether `identifier` quoting exists.
|
||||
*******************************************************************************/
|
||||
public boolean isBacktickIdentifiers()
|
||||
{
|
||||
return (backtickIdentifiers);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A small, dialect-aware SQL scanner. It is not a parser: it only knows
|
||||
** enough to find comments, string literals, quoted identifiers, dollar
|
||||
** quotes, statement separators and :named parameters, so that
|
||||
** classification and parameter binding never look inside a literal or a
|
||||
** comment.
|
||||
**
|
||||
** Unterminated literals and comments run to the end of the text (the
|
||||
** database reports the syntax error); the scanner never throws.
|
||||
**
|
||||
** MySQL executes some "comments": /*!... and /*M!... comments run their
|
||||
** contents as SQL, and /*+... carries optimizer hints. On the MySQL
|
||||
** dialect these become EXECUTABLE_COMMENT tokens (which the classifier
|
||||
** forbids) instead of being skipped. MySQL also needs whitespace after
|
||||
** "--" for a line comment ("1--1" is arithmetic), so on MySQL "--"
|
||||
** followed by anything else is scanned as symbols.
|
||||
*******************************************************************************/
|
||||
public class SqlScanner
|
||||
{
|
||||
private final String sql;
|
||||
private final SqlDialect dialect;
|
||||
private final int length;
|
||||
|
||||
private int pos = 0;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlScanner(String sql, SqlDialect dialect)
|
||||
{
|
||||
this.sql = sql == null ? "" : sql;
|
||||
this.dialect = dialect == null ? SqlDialect.GENERIC : dialect;
|
||||
this.length = this.sql.length();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan a SQL text into significant tokens.
|
||||
*******************************************************************************/
|
||||
public static List<SqlToken> scan(String sql, SqlDialect dialect)
|
||||
{
|
||||
return (new SqlScanner(sql, dialect).scanAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<SqlToken> scanAll()
|
||||
{
|
||||
List<SqlToken> tokens = new ArrayList<>();
|
||||
while(pos < length)
|
||||
{
|
||||
char c = sql.charAt(pos);
|
||||
|
||||
if(Character.isWhitespace(c))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
else if(c == '-' && peek(1) == '-' && isDashCommentStart())
|
||||
{
|
||||
skipLineComment();
|
||||
}
|
||||
else if(c == '#' && dialect.isHashComments())
|
||||
{
|
||||
skipLineComment();
|
||||
}
|
||||
else if(c == '/' && peek(1) == '*' && isExecutableCommentStart())
|
||||
{
|
||||
int start = pos;
|
||||
skipBlockComment();
|
||||
tokens.add(new SqlToken(SqlToken.Type.EXECUTABLE_COMMENT, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else if(c == '/' && peek(1) == '*')
|
||||
{
|
||||
skipBlockComment();
|
||||
}
|
||||
else if(c == '\'')
|
||||
{
|
||||
tokens.add(readQuoted(SqlToken.Type.STRING, '\'', dialect.isBackslashEscapes(), pos));
|
||||
}
|
||||
else if((c == 'E' || c == 'e') && peek(1) == '\'' && dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
int start = pos;
|
||||
pos++;
|
||||
tokens.add(readQuoted(SqlToken.Type.STRING, '\'', true, start));
|
||||
}
|
||||
else if(c == '"')
|
||||
{
|
||||
tokens.add(readQuoted(dialect == SqlDialect.MYSQL ? SqlToken.Type.STRING : SqlToken.Type.QUOTED_IDENTIFIER, '"', dialect.isBackslashEscapes() && dialect == SqlDialect.MYSQL, pos));
|
||||
}
|
||||
else if(c == '`' && dialect.isBacktickIdentifiers())
|
||||
{
|
||||
tokens.add(readQuoted(SqlToken.Type.QUOTED_IDENTIFIER, '`', false, pos));
|
||||
}
|
||||
else if(c == '[' && dialect == SqlDialect.SQLITE)
|
||||
{
|
||||
int start = pos;
|
||||
int close = sql.indexOf(']', pos + 1);
|
||||
pos = close < 0 ? length : close + 1;
|
||||
tokens.add(new SqlToken(SqlToken.Type.QUOTED_IDENTIFIER, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else if(c == '$' && dialect.isDollarQuotes() && isDollarQuoteStart())
|
||||
{
|
||||
tokens.add(readDollarQuoted());
|
||||
}
|
||||
else if(c == ';')
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.SEMICOLON, ";", pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
else if(c == '(')
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.OPEN_PAREN, "(", pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
else if(c == ')')
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.CLOSE_PAREN, ")", pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
else if(c == ':' && peek(1) == ':')
|
||||
{
|
||||
/////////////////////////////////////////
|
||||
// postgres cast operator, not a param //
|
||||
/////////////////////////////////////////
|
||||
tokens.add(new SqlToken(SqlToken.Type.SYMBOL, "::", pos, pos + 2));
|
||||
pos += 2;
|
||||
}
|
||||
else if(c == ':' && isIdentifierStart(peek(1)) && !isPreviousCharColon())
|
||||
{
|
||||
int start = pos;
|
||||
pos++;
|
||||
int nameStart = pos;
|
||||
while(pos < length && isIdentifierPart(sql.charAt(pos)))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
tokens.add(new SqlToken(SqlToken.Type.PARAMETER, sql.substring(nameStart, pos), start, pos));
|
||||
}
|
||||
else if(isIdentifierStart(c))
|
||||
{
|
||||
int start = pos;
|
||||
while(pos < length && (isIdentifierPart(sql.charAt(pos)) || sql.charAt(pos) == '$'))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
tokens.add(new SqlToken(SqlToken.Type.WORD, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else if(Character.isDigit(c))
|
||||
{
|
||||
int start = pos;
|
||||
while(pos < length && (Character.isLetterOrDigit(sql.charAt(pos)) || sql.charAt(pos) == '.'))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
tokens.add(new SqlToken(SqlToken.Type.NUMBER, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.SYMBOL, String.valueOf(c), pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
return (tokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private char peek(int offset)
|
||||
{
|
||||
int i = pos + offset;
|
||||
return (i < length ? sql.charAt(i) : '\0');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** At "--": a line comment everywhere but MySQL, where the dashes must be
|
||||
** followed by whitespace, a control character, or the end of the text.
|
||||
*******************************************************************************/
|
||||
private boolean isDashCommentStart()
|
||||
{
|
||||
if(dialect != SqlDialect.MYSQL)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
char next = peek(2);
|
||||
return (next == '\0' || Character.isWhitespace(next) || Character.isISOControl(next));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** At "/*" on MySQL: /*!, /*M! or /*+, which the server executes.
|
||||
*******************************************************************************/
|
||||
private boolean isExecutableCommentStart()
|
||||
{
|
||||
if(dialect != SqlDialect.MYSQL)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
char third = peek(2);
|
||||
return (third == '!' || third == '+' || (third == 'M' && peek(3) == '!'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private boolean isPreviousCharColon()
|
||||
{
|
||||
return (pos > 0 && sql.charAt(pos - 1) == ':');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isIdentifierStart(char c)
|
||||
{
|
||||
return (Character.isLetter(c) || c == '_');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isIdentifierPart(char c)
|
||||
{
|
||||
return (Character.isLetterOrDigit(c) || c == '_');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void skipLineComment()
|
||||
{
|
||||
while(pos < length && sql.charAt(pos) != '\n')
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Block comments; postgres nests them, the others do not.
|
||||
*******************************************************************************/
|
||||
private void skipBlockComment()
|
||||
{
|
||||
int depth = 0;
|
||||
while(pos < length)
|
||||
{
|
||||
if(sql.charAt(pos) == '/' && peek(1) == '*')
|
||||
{
|
||||
if(depth == 0 || dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
pos += 2;
|
||||
}
|
||||
else if(sql.charAt(pos) == '*' && peek(1) == '/')
|
||||
{
|
||||
depth--;
|
||||
pos += 2;
|
||||
if(depth == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a quoted run starting at the quote character at pos; a doubled
|
||||
** quote is an escaped quote.
|
||||
*******************************************************************************/
|
||||
private SqlToken readQuoted(SqlToken.Type type, char quote, boolean backslashEscapes, int tokenStart)
|
||||
{
|
||||
pos++;
|
||||
while(pos < length)
|
||||
{
|
||||
char c = sql.charAt(pos);
|
||||
if(backslashEscapes && c == '\\')
|
||||
{
|
||||
pos += 2;
|
||||
}
|
||||
else if(c == quote)
|
||||
{
|
||||
if(peek(1) == quote)
|
||||
{
|
||||
pos += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
pos++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
pos = Math.min(pos, length);
|
||||
return (new SqlToken(type, sql.substring(tokenStart, pos), tokenStart, pos));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** $$ or $tag$ where tag is an identifier (and not a $1 positional param).
|
||||
*******************************************************************************/
|
||||
private boolean isDollarQuoteStart()
|
||||
{
|
||||
if(pos > 0 && isIdentifierPart(sql.charAt(pos - 1)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int i = pos + 1;
|
||||
if(i < length && sql.charAt(i) == '$')
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(i >= length || !isIdentifierStart(sql.charAt(i)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
while(i < length && isIdentifierPart(sql.charAt(i)))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
return (i < length && sql.charAt(i) == '$');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlToken readDollarQuoted()
|
||||
{
|
||||
int start = pos;
|
||||
int tagClose = sql.indexOf('$', pos + 1);
|
||||
String tag = sql.substring(pos, tagClose + 1);
|
||||
int close = sql.indexOf(tag, tagClose + 1);
|
||||
pos = close < 0 ? length : close + tag.length();
|
||||
return (new SqlToken(SqlToken.Type.STRING, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One statement of a SQL text, with its classification.
|
||||
**
|
||||
** @param text the statement's source text (no trailing ';')
|
||||
** @param tokens its significant tokens (offsets are into the full text)
|
||||
** @param kind its classification
|
||||
** @param leadingKeyword its first keyword, upper case (null if none)
|
||||
** @param reason why it got this kind, for messages
|
||||
*******************************************************************************/
|
||||
public record SqlStatement(String text, List<SqlToken> tokens, StatementKind kind, String leadingKeyword, String reason)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Names of the :parameters this statement uses, in order of appearance
|
||||
** (repeats included).
|
||||
*******************************************************************************/
|
||||
public List<String> parameterNames()
|
||||
{
|
||||
return (tokens.stream().filter(t -> t.type() == SqlToken.Type.PARAMETER).map(SqlToken::text).toList());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One significant token of a SQL text. Comments and whitespace produce no
|
||||
** tokens; start and end are offsets into the original text (end exclusive).
|
||||
**
|
||||
** @param type kind of token
|
||||
** @param text the token's source text (for PARAMETER, the name without ':')
|
||||
** @param start offset of the token's first character
|
||||
** @param end offset after the token's last character
|
||||
*******************************************************************************/
|
||||
public record SqlToken(Type type, String text, int start, int end)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Token kinds.
|
||||
*******************************************************************************/
|
||||
public enum Type
|
||||
{
|
||||
WORD,
|
||||
QUOTED_IDENTIFIER,
|
||||
STRING,
|
||||
NUMBER,
|
||||
PARAMETER,
|
||||
SEMICOLON,
|
||||
OPEN_PAREN,
|
||||
CLOSE_PAREN,
|
||||
SYMBOL,
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// a MySQL / MariaDB comment the server executes: /*! ... */, //
|
||||
// /*M! ... */ (version-conditional SQL) or /*+ ... */ (optimizer //
|
||||
// hints). Its contents are SQL, so it is a token, not a comment. //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
EXECUTABLE_COMMENT
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Upper-cased text of a WORD token, or null for any other token.
|
||||
*******************************************************************************/
|
||||
public String keyword()
|
||||
{
|
||||
return (type == Type.WORD ? text.toUpperCase(Locale.ROOT) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this is a WORD equal (ignoring case) to the given keyword.
|
||||
*******************************************************************************/
|
||||
public boolean isKeyword(String keyword)
|
||||
{
|
||||
return (type == Type.WORD && text.equalsIgnoreCase(keyword));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a statement does, as far as the workbench's gating is concerned.
|
||||
**
|
||||
** READ - SELECT / WITH / VALUES / TABLE / SHOW / DESCRIBE /
|
||||
** EXPLAIN (without ANALYZE) / read-only PRAGMA. Allowed
|
||||
** in read-only sessions.
|
||||
** EXPLAIN_ANALYZE - EXPLAIN ANALYZE executes its statement: needs the
|
||||
** write permission and an explicit request.
|
||||
** WRITE - DML (INSERT, UPDATE, DELETE, MERGE, ...). Write mode.
|
||||
** DDL - CREATE, ALTER, DROP, TRUNCATE, GRANT, ... Write mode;
|
||||
** dry run only where DDL is transactional.
|
||||
** FORBIDDEN - transaction and session control (BEGIN, COMMIT, SET,
|
||||
** USE, ATTACH, ...) and file/system commands. The engine
|
||||
** owns transactions and session state, so these are
|
||||
** refused in every mode.
|
||||
*******************************************************************************/
|
||||
public enum StatementKind
|
||||
{
|
||||
READ,
|
||||
EXPLAIN_ANALYZE,
|
||||
WRITE,
|
||||
DDL,
|
||||
FORBIDDEN;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a statement of this kind may run in a read-only session.
|
||||
*******************************************************************************/
|
||||
public boolean isReadOnlySafe()
|
||||
{
|
||||
return (this == READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a statement of this kind changes data or schema.
|
||||
*******************************************************************************/
|
||||
public boolean isMutating()
|
||||
{
|
||||
return (this == WRITE || this == DDL);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
version=${project.version}
|
||||
@@ -1,293 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
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.code.QCodeReference;
|
||||
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.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test base: an H2 (MODE=MySQL) backend "h2" holding the qbit's tables
|
||||
** (snake_case, as hosts map them) and a "person" table, the qbit produced
|
||||
** into a QInstance, and helpers for sessions and callers.
|
||||
*******************************************************************************/
|
||||
public class BaseSqlWorkbenchTest
|
||||
{
|
||||
public static final String BACKEND_NAME = "h2";
|
||||
public static final String READER = "reader-1";
|
||||
public static final String WRITER = "writer-1";
|
||||
public static final String OTHER_READER = "reader-2";
|
||||
public static final String ADMIN = "admin-1";
|
||||
|
||||
protected QInstance qInstance;
|
||||
protected SqlWorkbenchQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The H2 backend.
|
||||
*******************************************************************************/
|
||||
public static RDBMSBackendMetaData defineH2Backend(String name)
|
||||
{
|
||||
RDBMSBackendMetaData backend = new RDBMSBackendMetaData()
|
||||
.withName(name)
|
||||
.withVendor("h2")
|
||||
.withHostName("mem")
|
||||
.withDatabaseName("sqlwb_" + name)
|
||||
.withUsername("sa");
|
||||
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE");
|
||||
return (backend);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The snake_case customizer hosts use for the qbit's tables.
|
||||
*******************************************************************************/
|
||||
public static class SnakeCaseCustomizer implements MetaDataCustomizerInterface<QTableMetaData>
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table)
|
||||
{
|
||||
table.setBackendDetails(new RDBMSTableBackendDetails().withTableName(QInstanceEnricher.inferBackendName(table.getName())));
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A default config for tests.
|
||||
*******************************************************************************/
|
||||
protected SqlWorkbenchQBitConfig defineConfig()
|
||||
{
|
||||
return (new SqlWorkbenchQBitConfig()
|
||||
.withBackendName(BACKEND_NAME)
|
||||
.withTableMetaDataCustomizer(new SnakeCaseCustomizer()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the instance, prime the database, init the context as a reader.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void baseSetUp() throws Exception
|
||||
{
|
||||
qInstance = new QInstance();
|
||||
qInstance.addBackend(defineH2Backend(BACKEND_NAME));
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(TestSessionCustomizer.class)));
|
||||
|
||||
QTableMetaData person = new QTableMetaData()
|
||||
.withName("person")
|
||||
.withLabel("Person")
|
||||
.withBackendName(BACKEND_NAME)
|
||||
.withPrimaryKeyField("id")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("person"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("firstName", QFieldType.STRING).withBackendName("first_name").withLabel("First Name"))
|
||||
.withField(new QFieldMetaData("lastName", QFieldType.STRING).withBackendName("last_name"));
|
||||
qInstance.addTable(person);
|
||||
|
||||
config = defineConfig();
|
||||
customizeConfig(config);
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
|
||||
primeDatabase(qInstance, BACKEND_NAME, "sql-workbench-h2.sql");
|
||||
loginAs(READER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hook for subclasses to change the config before production.
|
||||
*******************************************************************************/
|
||||
protected void customizeConfig(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void baseTearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a SQL script (statements split on ';' at line ends).
|
||||
*******************************************************************************/
|
||||
public static void primeDatabase(QInstance qInstance, String backendName, String resource) throws Exception
|
||||
{
|
||||
String script;
|
||||
try(InputStream in = BaseSqlWorkbenchTest.class.getClassLoader().getResourceAsStream(resource))
|
||||
{
|
||||
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
List<String> statements = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
for(String line : script.split("\n"))
|
||||
{
|
||||
if(line.trim().startsWith("--"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
current.append(line).append('\n');
|
||||
if(line.trim().endsWith(";"))
|
||||
{
|
||||
statements.add(current.toString().trim().replaceAll(";$", ""));
|
||||
current.setLength(0);
|
||||
}
|
||||
}
|
||||
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(backendName)); Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : statements)
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Init QContext with a session for one of the test users.
|
||||
*******************************************************************************/
|
||||
protected QSession loginAs(String userId, String... groups)
|
||||
{
|
||||
QSession session = TestSessionCustomizer.sessionFor(userId);
|
||||
QContext.init(qInstance, session);
|
||||
SqlWorkbenchSecurity.applyToSession(config, session, List.of(groups));
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log in as a user and return their Caller (the session and the Caller
|
||||
** must agree: notebook operations run as the session).
|
||||
*******************************************************************************/
|
||||
protected Caller as(String userId, String... groups)
|
||||
{
|
||||
loginAs(userId, groups);
|
||||
return (caller(userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The caller for a test user (permissions as TestSessionCustomizer grants).
|
||||
*******************************************************************************/
|
||||
protected Caller caller(String userId)
|
||||
{
|
||||
boolean writer = userId.startsWith("writer");
|
||||
return (new Caller(userId, "Name of " + userId, writer, writer, "127.0.0.1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Grants permissions by the session id's prefix: "writer-*" gets access,
|
||||
** write and share; "reader-*" gets access; "admin-*" gets access and the
|
||||
** admin permission (the generic table UI / API); anything else nothing.
|
||||
** The user id is the session id.
|
||||
*******************************************************************************/
|
||||
public static class TestSessionCustomizer implements com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
QSession granted = sessionFor(qSession.getUuid());
|
||||
qSession.setUser(granted.getUser());
|
||||
qSession.setPermissions(granted.getPermissions());
|
||||
try
|
||||
{
|
||||
SqlWorkbenchSecurity.applyToSession(qInstance, qSession, List.of());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException("SQL Workbench is not installed", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QSession sessionFor(String userId)
|
||||
{
|
||||
SqlWorkbenchQBitConfig defaults = new SqlWorkbenchQBitConfig();
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Name of " + userId));
|
||||
session.setPermissions(new java.util.HashSet<>());
|
||||
if(userId != null && (userId.startsWith("reader") || userId.startsWith("writer")))
|
||||
{
|
||||
session.getPermissions().addAll(defaults.getUserPermissionNames());
|
||||
}
|
||||
if(userId != null && userId.startsWith("admin"))
|
||||
{
|
||||
session.getPermissions().addAll(defaults.getAdminPermissionNames());
|
||||
}
|
||||
if(userId != null && userId.startsWith("writer"))
|
||||
{
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_WRITE_PERMISSION_NAME);
|
||||
session.getPermissions().addAll(defaults.getSharePermissionNames());
|
||||
}
|
||||
return (session);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
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.code.QCodeReference;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionRequest;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.SessionGuard;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.SqlExecutionEngine;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.schema.SchemaIntrospector;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.C3P0PooledConnectionProvider;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.ConnectionPoolSettings;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChartData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The engine against a real PostgreSQL (Testcontainers): read-only
|
||||
** transactions, timeouts, cancellation, clean pooled connections,
|
||||
** read-only credentials, write preview / commit, transactional DDL dry
|
||||
** runs, EXPLAIN (ANALYZE) with rollback, types, schema, and notebooks.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Testcontainers
|
||||
class PostgresIntegrationTest
|
||||
{
|
||||
private static final String BACKEND = "pg";
|
||||
|
||||
@Container
|
||||
@SuppressWarnings("resource")
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse(System.getProperty("postgres.test.image", "postgres:16-alpine"))
|
||||
.asCompatibleSubstituteFor("postgres"));
|
||||
|
||||
private QInstance qInstance;
|
||||
private SqlWorkbenchQBitConfig config;
|
||||
private SqlExecutionEngine engine;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Schema, data, and a SELECT-only role, once.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void primeDatabase() throws Exception
|
||||
{
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute(Files.readString(Path.of("docs/ddl/postgres.sql")));
|
||||
statement.execute("""
|
||||
CREATE TABLE person (id SERIAL PRIMARY KEY, first_name TEXT, salary NUMERIC(12,2), tags TEXT[], doc JSONB, ref UUID, seen TIMESTAMPTZ);
|
||||
INSERT INTO person (first_name, salary, tags, doc, ref, seen) VALUES
|
||||
('Darin', 25000, ARRAY['a','b'], '{"k": 1}', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '2026-01-01T10:00:00Z'),
|
||||
('James', 26000, NULL, NULL, NULL, NULL),
|
||||
('Tim', NULL, ARRAY[]::TEXT[], '[1,2]', NULL, NULL);
|
||||
CREATE ROLE sqlwb_ro LOGIN PASSWORD 'ro-pass';
|
||||
GRANT SELECT ON person TO sqlwb_ro;
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
ConnectionManager.resetConnectionProviders();
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("TRUNCATE sql_workbench_notebook_share, sql_workbench_result_snapshot, sql_workbench_notebook_version, sql_workbench_cell, sql_workbench_notebook, sql_workbench_execution");
|
||||
statement.execute("DROP TABLE IF EXISTS scratch");
|
||||
}
|
||||
|
||||
PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData()
|
||||
.withName(BACKEND)
|
||||
.withHostName(POSTGRES.getHost())
|
||||
.withPort(POSTGRES.getFirstMappedPort())
|
||||
.withDatabaseName(POSTGRES.getDatabaseName())
|
||||
.withUsername(POSTGRES.getUsername())
|
||||
.withPassword(POSTGRES.getPassword());
|
||||
backend.setConnectionProvider(new QCodeReference(C3P0PooledConnectionProvider.class));
|
||||
backend.setConnectionPoolSettings(new ConnectionPoolSettings().withInitialPoolSize(1).withMinPoolSize(1).withMaxPoolSize(1));
|
||||
|
||||
qInstance = new QInstance();
|
||||
qInstance.addBackend(backend);
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
|
||||
config = new SqlWorkbenchQBitConfig().withBackendName(BACKEND).withTableMetaDataCustomizer(new BaseSqlWorkbenchTest.SnakeCaseCustomizer()).withRegisterRoutes(false);
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
QContext.init(qInstance, BaseSqlWorkbenchTest.TestSessionCustomizer.sessionFor("writer-1"));
|
||||
engine = new SqlExecutionEngine(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
ConnectionManager.resetConnectionProviders();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Caller reader()
|
||||
{
|
||||
return (new Caller("reader-1", "Reader", false, false, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Caller writer()
|
||||
{
|
||||
return (new Caller("writer-1", "Writer", true, true, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> read(String sql) throws Exception
|
||||
{
|
||||
return (engine.execute(ExecutionRequest.readOnly(BACKEND, sql, Map.of()), reader()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The database itself refuses writes in a workbench read-only session,
|
||||
** even for SQL the classifier would never let through.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReadOnlyTransactionIsEnforcedByPostgres() throws Exception
|
||||
{
|
||||
Connection connection = ConnectionManager.getConnection((PostgreSQLBackendMetaData) qInstance.getBackend(BACKEND));
|
||||
try(SessionGuard guard = new SessionGuard(connection, SqlDialect.POSTGRES, true, 5).begin(); Statement statement = guard.getConnection().createStatement())
|
||||
{
|
||||
assertThatThrownBy(() -> statement.execute("INSERT INTO person (first_name) VALUES ('nope')"))
|
||||
.isInstanceOf(SQLException.class).hasMessageContaining("read-only transaction");
|
||||
}
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM person WHERE first_name = 'nope'")).isZero();
|
||||
|
||||
assertThatThrownBy(() -> read("WITH d AS (DELETE FROM person RETURNING *) SELECT * FROM d")).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> read("SELECT nextval('person_id_seq')")).isInstanceOf(QBadRequestException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pool of one connection: whatever a session did, the next borrower gets
|
||||
** autocommit on, read-write, and no statement_timeout.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPooledConnectionIsReturnedClean() throws Exception
|
||||
{
|
||||
assertThat(read("SELECT count(*) FROM person").get("status")).isEqualTo("SUCCEEDED");
|
||||
config.setStatementTimeoutSeconds(1);
|
||||
assertThat(read("SELECT pg_sleep(3)").get("status")).isEqualTo("TIMED_OUT");
|
||||
|
||||
try(Connection connection = ConnectionManager.getConnection((PostgreSQLBackendMetaData) qInstance.getBackend(BACKEND)); Statement statement = connection.createStatement())
|
||||
{
|
||||
assertThat(connection.getAutoCommit()).isTrue();
|
||||
assertThat(connection.isReadOnly()).isFalse();
|
||||
try(ResultSet rs = statement.executeQuery("SELECT current_setting('transaction_read_only'), current_setting('statement_timeout')"))
|
||||
{
|
||||
rs.next();
|
||||
assertThat(rs.getString(1)).isEqualTo("off");
|
||||
assertThat(rs.getString(2)).isEqualTo("0");
|
||||
}
|
||||
statement.execute("INSERT INTO person (first_name) VALUES ('after')");
|
||||
statement.execute("DELETE FROM person WHERE first_name = 'after'");
|
||||
}
|
||||
|
||||
List<SqlWorkbenchExecution> audits = SystemUser.call(() -> QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter()));
|
||||
assertThat(audits).extracting(SqlWorkbenchExecution::getStatus).contains("SUCCEEDED", "TIMED_OUT");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCancel() throws Exception
|
||||
{
|
||||
SqlWorkbenchRuntime runtime = SqlWorkbenchRuntime.get(qInstance);
|
||||
var captured = QContext.capture();
|
||||
CompletableFuture<Map<String, Object>> future = CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.init(captured);
|
||||
return (read("SELECT pg_sleep(20)"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new RuntimeException(e));
|
||||
}
|
||||
});
|
||||
|
||||
String uuid = null;
|
||||
for(int i = 0; i < 100 && uuid == null; i++)
|
||||
{
|
||||
Thread.sleep(100);
|
||||
uuid = QueryCount.string("SELECT execution_uuid FROM sql_workbench_execution WHERE status = 'RUNNING'");
|
||||
}
|
||||
assertThat(uuid).isNotNull();
|
||||
Thread.sleep(300);
|
||||
assertThat(engine.cancel(uuid, reader())).isTrue();
|
||||
assertThat(future.get(10, TimeUnit.SECONDS).get("status")).isEqualTo("CANCELLED");
|
||||
assertThat(runtime.getRunningStatements().size()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReadOnlyCredentials() throws Exception
|
||||
{
|
||||
config.withReadOnlyCredentials(BACKEND, new SqlWorkbenchCredentials().withUsername("sqlwb_ro").withPassword("ro-pass"));
|
||||
assertThat(read("SELECT current_user").get("rows").toString()).contains("sqlwb_ro");
|
||||
assertThat(read("SELECT count(*) FROM person").get("status")).isEqualTo("SUCCEEDED");
|
||||
|
||||
Map<String, Object> denied = read("SELECT * FROM sql_workbench_execution");
|
||||
assertThat(denied.get("status")).isEqualTo("FAILED");
|
||||
assertThat((String) denied.get("error")).containsIgnoringCase("permission denied");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWriteAndTransactionalDdl() throws Exception
|
||||
{
|
||||
String sql = "UPDATE person SET salary = salary + 1 WHERE salary IS NOT NULL";
|
||||
Map<String, Object> preview = engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, null, false), writer());
|
||||
assertThat(preview.get("totalAffectedRows")).isEqualTo(2L);
|
||||
Map<String, Object> commit = engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, (String) preview.get("confirmToken"), false), writer());
|
||||
assertThat(commit.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM person WHERE salary = 25001")).isEqualTo(1);
|
||||
engine.execute(new ExecutionRequest(BACKEND, "UPDATE person SET salary = salary - 1 WHERE salary IS NOT NULL", Map.of(), true, null, null, null, null, false), writer());
|
||||
|
||||
String ddl = "CREATE TABLE scratch (id INT); INSERT INTO scratch VALUES (1), (2)";
|
||||
Map<String, Object> ddlPreview = engine.execute(new ExecutionRequest(BACKEND, ddl, Map.of(), true, null, null, null, null, false), writer());
|
||||
assertThat(ddlPreview.get("dryRunSupported")).isEqualTo(true);
|
||||
assertThat(ddlPreview.get("totalAffectedRows")).isEqualTo(2L);
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM information_schema.tables WHERE table_name = 'scratch'")).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testExplainTypesSchemaAndNotebooks() throws Exception
|
||||
{
|
||||
Map<String, Object> explain = engine.explain(BACKEND, "SELECT * FROM person WHERE id = :id", Map.of("id", 1), false, reader());
|
||||
Map<String, Object> plan = (Map<String, Object>) explain.get("plan");
|
||||
assertThat(plan.get("label")).isNotNull();
|
||||
assertThat(plan.get("totalCost")).isNotNull();
|
||||
|
||||
Map<String, Object> analyzed = engine.explain(BACKEND, "DELETE FROM person WHERE id = 1", Map.of(), true, writer());
|
||||
assertThat(((Map<String, Object>) analyzed.get("plan")).get("actualTimeMs")).isNotNull();
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM person WHERE id = 1")).isEqualTo(1);
|
||||
|
||||
Map<String, Object> typed = engine.execute(ExecutionRequest.readOnly(BACKEND, "SELECT tags, doc, ref, seen FROM person WHERE seen > :since", Map.of("since", "2025-01-01")), reader());
|
||||
List<Map<String, Object>> columns = (List<Map<String, Object>>) typed.get("columns");
|
||||
assertThat(columns).extracting(c -> c.get("kind")).containsExactly("array", "json", "other", "datetime");
|
||||
List<Object> row = ((List<List<Object>>) typed.get("rows")).get(0);
|
||||
assertThat(row.get(0)).isEqualTo(List.of("a", "b"));
|
||||
assertThat(row.get(1)).isEqualTo("{\"k\": 1}");
|
||||
assertThat(row.get(2)).isEqualTo("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
assertThat(java.time.OffsetDateTime.parse((String) row.get(3)).toInstant()).isEqualTo(java.time.Instant.parse("2026-01-01T10:00:00Z"));
|
||||
|
||||
Map<String, Object> schema = new SchemaIntrospector(qInstance, SqlWorkbenchRuntime.get(qInstance), engine.getBackends()).describe(BACKEND, true);
|
||||
assertThat(schema.get("dialect")).isEqualTo("postgresql");
|
||||
assertThat(schema.toString()).contains("name=public").contains("name=person").contains("first_name").doesNotContain("pg_catalog");
|
||||
|
||||
NotebookService notebooks = new NotebookService(SqlWorkbenchRuntime.get(qInstance));
|
||||
Integer id = (Integer) notebooks.create(Map.of("title", "PG", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 1"))), writer()).get("id");
|
||||
notebooks.update(id, Map.of("title", "PG 2"), writer());
|
||||
assertThat(notebooks.versions(id, writer())).hasSize(2);
|
||||
notebooks.delete(id, writer());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One-value queries against the container as the admin user.
|
||||
*******************************************************************************/
|
||||
static class QueryCount
|
||||
{
|
||||
/*******************************************************************************
|
||||
** First column of the first row as a string, or null if no rows.
|
||||
*******************************************************************************/
|
||||
static String string(String sql) throws SQLException
|
||||
{
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
return (rs.next() ? rs.getString(1) : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static int of(String sql) throws SQLException
|
||||
{
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
return (rs.getInt(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record locks on PostgreSQL: owner / user share / library reads
|
||||
** through the core actions, one row per notebook even when it matches
|
||||
** several shares (SELECT DISTINCT / COUNT(DISTINCT) over TEXT columns),
|
||||
** and a reader cannot write a shared notebook's children.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordLocksOnPostgres() throws Exception
|
||||
{
|
||||
NotebookService notebooks = new NotebookService(SqlWorkbenchRuntime.get(qInstance));
|
||||
login("writer-1");
|
||||
Integer both = (Integer) notebooks.create(Map.of("title", "Both", "description", "long text", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 1"))), writer()).get("id");
|
||||
Integer personal = (Integer) notebooks.create(Map.of("title", "Personal", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 2"))), writer()).get("id");
|
||||
notebooks.publish(both, true, writer());
|
||||
QRecord share = new InsertAction().execute(new InsertInput(SqlWorkbenchNotebookShare.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("notebookId", both).withValue("audienceType", "USER").withValue("audienceId", "reader-2"))).getRecords().get(0);
|
||||
assertThat(share.getErrors()).isNullOrEmpty();
|
||||
|
||||
login("reader-2");
|
||||
assertThat(QueryAction.execute(SqlWorkbenchNotebook.TABLE_NAME, new QQueryFilter())).extracting(r -> r.getValueInteger("id")).containsExactly(both);
|
||||
assertThat(new CountAction().execute(new CountInput(SqlWorkbenchNotebook.TABLE_NAME)).getCount()).isEqualTo(1);
|
||||
assertThat(QueryAction.execute(SqlWorkbenchCell.TABLE_NAME, new QQueryFilter())).hasSize(1);
|
||||
assertThat(new CountAction().execute(new CountInput(SqlWorkbenchCell.TABLE_NAME)).getCount()).isEqualTo(1);
|
||||
assertThat(QueryAction.execute(SqlWorkbenchNotebookShare.TABLE_NAME, new QQueryFilter())).hasSize(2);
|
||||
assertThat(new InsertAction().execute(new InsertInput(SqlWorkbenchCell.TABLE_NAME).withRecord(new QRecord().withValue("notebookId", both).withValue("content", "x")))
|
||||
.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
assertThat(new InsertAction().execute(new InsertInput(SqlWorkbenchCell.TABLE_NAME).withRecord(new QRecord().withValue("notebookId", personal).withValue("content", "x")))
|
||||
.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
|
||||
login("admin-1");
|
||||
assertThat(new CountAction().execute(new CountInput(SqlWorkbenchNotebook.TABLE_NAME)).getCount()).isEqualTo(2);
|
||||
|
||||
login("writer-1");
|
||||
notebooks.delete(both, writer());
|
||||
assertThat(new CountAction().execute(new CountInput(SqlWorkbenchNotebookShare.TABLE_NAME)).getCount()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home dashboard over real engine runs on Postgres (aggregates, IN
|
||||
** filters and timestamps through the RDBMS module): a reader sees their
|
||||
** own runs, an admin everyone's, including the committed write.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHomeDashboardOnPostgres() throws Exception
|
||||
{
|
||||
NotebookService notebooks = new NotebookService(SqlWorkbenchRuntime.get(qInstance));
|
||||
login("writer-1");
|
||||
Integer id = (Integer) notebooks.create(Map.of("title", "Dashboard source", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 1"))), writer()).get("id");
|
||||
engine.execute(new ExecutionRequest(BACKEND, "SELECT count(*) FROM person", Map.of(), false, id, null, null, null, false), writer());
|
||||
String sql = "UPDATE person SET salary = salary + 0 WHERE salary IS NOT NULL";
|
||||
Map<String, Object> preview = engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, null, false), writer());
|
||||
engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, (String) preview.get("confirmToken"), false), writer());
|
||||
login("reader-1");
|
||||
engine.execute(new ExecutionRequest(BACKEND, "SELECT broken FROM nowhere", Map.of(), false, null, null, null, null, false), reader());
|
||||
|
||||
login("admin-1");
|
||||
Map<String, String> html = new HashMap<>();
|
||||
for(String name : SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames())
|
||||
{
|
||||
QWidgetData data = new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData((QWidgetMetaData) qInstance.getWidget(name))).getWidgetData();
|
||||
html.put(name, data instanceof TableData table ? table.getRows().toString() : data instanceof RawHTML raw ? raw.getHtml() : String.valueOf(((ChartData) data).getSublabel()));
|
||||
}
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES)).contains("UPDATE person", "<strong>2</strong>");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED)).contains("SELECT broken FROM nowhere");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED)).contains("Dashboard source");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER)).contains("writer-1", "reader-1");
|
||||
assertThat(html.get(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).contains("Writes, 30 Days", "2 rows affected");
|
||||
|
||||
login("reader-1");
|
||||
TableData recent = (TableData) new RenderWidgetAction().execute(new RenderWidgetInput()
|
||||
.withWidgetMetaData((QWidgetMetaData) qInstance.getWidget(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS))).getWidgetData();
|
||||
assertThat(recent.getRows().toString()).contains("SELECT broken").doesNotContain("UPDATE person");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log in with the workbench security keys.
|
||||
*******************************************************************************/
|
||||
private void login(String userId)
|
||||
{
|
||||
QSession session = BaseSqlWorkbenchTest.TestSessionCustomizer.sessionFor(userId);
|
||||
QContext.init(qInstance, session);
|
||||
SqlWorkbenchSecurity.applyToSession(config, session, List.of());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import org.junit.jupiter.api.extension.ExecutionCondition;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JUnit condition for integration tests that need Docker.
|
||||
**
|
||||
** Without Docker, the tests are skipped on a developer machine, but fail when
|
||||
** CI=true, so a CI build can never pass with its integration tests silently
|
||||
** skipped. Register it ahead of @Testcontainers, so no container is started
|
||||
** when the tests are disabled.
|
||||
*******************************************************************************/
|
||||
public class RequiresDockerCondition implements ExecutionCondition
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate against the local Docker environment and the CI env var.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context)
|
||||
{
|
||||
return (evaluate(DockerClientFactory.instance().isDockerAvailable(), System.getenv("CI")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decide whether Docker-backed tests run, skip, or fail.
|
||||
**
|
||||
** @param dockerAvailable whether a usable Docker environment was found
|
||||
** @param ciValue value of the CI environment variable (may be null)
|
||||
** @return enabled when Docker is available; disabled when it is not and CI
|
||||
** is not "true"
|
||||
** @throws IllegalStateException when Docker is unavailable and CI is "true"
|
||||
*******************************************************************************/
|
||||
static ConditionEvaluationResult evaluate(Boolean dockerAvailable, String ciValue)
|
||||
{
|
||||
if(Boolean.TRUE.equals(dockerAvailable))
|
||||
{
|
||||
return (ConditionEvaluationResult.enabled("Docker is available"));
|
||||
}
|
||||
|
||||
if("true".equalsIgnoreCase(ciValue))
|
||||
{
|
||||
throw (new IllegalStateException("Docker is required for integration tests when CI=true, but no usable Docker environment was found"));
|
||||
}
|
||||
|
||||
return (ConditionEvaluationResult.disabled("Docker is not available; skipping (with CI=true these tests fail instead)"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for RequiresDockerCondition.
|
||||
*******************************************************************************/
|
||||
class RequiresDockerConditionTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Docker available: integration tests run, whether or not CI is set.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_dockerAvailable_enabled()
|
||||
{
|
||||
ConditionEvaluationResult local = RequiresDockerCondition.evaluate(true, null);
|
||||
ConditionEvaluationResult ci = RequiresDockerCondition.evaluate(true, "true");
|
||||
|
||||
assertThat(local.isDisabled()).isFalse();
|
||||
assertThat(ci.isDisabled()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker outside CI: integration tests are skipped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_noDockerOutsideCi_disabled()
|
||||
{
|
||||
assertThat(RequiresDockerCondition.evaluate(false, null).isDisabled()).isTrue();
|
||||
assertThat(RequiresDockerCondition.evaluate(false, "").isDisabled()).isTrue();
|
||||
assertThat(RequiresDockerCondition.evaluate(false, "false").isDisabled()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker with CI=true: evaluation throws, so the tests fail instead of
|
||||
** silently skipping.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_noDockerInCi_throws()
|
||||
{
|
||||
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "true"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("CI=true");
|
||||
|
||||
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "TRUE"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetRenderer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.ShareAudienceType;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookShare;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer output and config validation.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testProducedMetaData() throws Exception
|
||||
{
|
||||
assertThat(qInstance.getQBits().values()).anySatisfy(q ->
|
||||
{
|
||||
assertThat(q.getGroupId()).isEqualTo("com.kingsrook.qbits");
|
||||
assertThat(q.getArtifactId()).isEqualTo("sql-workbench");
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// name -> {insert, update, delete} capabilities: notebooks, cells and //
|
||||
// shares are fully editable; pinned results can be relabeled / deleted //
|
||||
// but only pinned from the notebook UI; versions and the audit are //
|
||||
// append-only. Every table is a standard per-table-permission table //
|
||||
// with a record security lock, and none is hidden. //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
Map<String, List<Boolean>> capabilities = new java.util.LinkedHashMap<>();
|
||||
capabilities.put("sql_workbench_notebook", List.of(true, true, true));
|
||||
capabilities.put("sql_workbench_cell", List.of(true, true, true));
|
||||
capabilities.put("sql_workbench_notebook_share", List.of(true, true, true));
|
||||
capabilities.put("sql_workbench_result_snapshot", List.of(false, true, true));
|
||||
capabilities.put("sql_workbench_notebook_version", List.of(false, false, false));
|
||||
capabilities.put("sql_workbench_execution", List.of(false, false, false));
|
||||
for(Map.Entry<String, List<Boolean>> entry : capabilities.entrySet())
|
||||
{
|
||||
String name = entry.getKey();
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
assertThat(table).as(name).isNotNull();
|
||||
assertThat(table.getBackendName()).isEqualTo(BACKEND_NAME);
|
||||
assertThat(table.getIsHidden()).as(name).isFalse();
|
||||
assertThat(table.getPermissionRules().getLevel()).as(name).isEqualTo(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS);
|
||||
assertThat(table.getRecordSecurityLocks()).as(name).isNotEmpty();
|
||||
com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData backend = qInstance.getBackend(BACKEND_NAME);
|
||||
assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT)).as(name + " insert").isEqualTo(entry.getValue().get(0));
|
||||
assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE)).as(name + " update").isEqualTo(entry.getValue().get(1));
|
||||
assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)).as(name + " delete").isEqualTo(entry.getValue().get(2));
|
||||
assertThat(table.isCapabilityEnabled(backend, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_EXPORT)).as(name + " export").isTrue();
|
||||
assertThat(((RDBMSTableBackendDetails) table.getBackendDetails()).getTableName()).startsWith("sql_workbench_");
|
||||
assertThat(table.getSourceQBitName()).isNotBlank();
|
||||
}
|
||||
assertThat(qInstance.getSecurityKeyType("sql_workbench_user").getAllAccessKeyName()).isEqualTo("sql_workbench_all_access");
|
||||
assertThat(qInstance.getSecurityKeyType("sql_workbench_audience")).isNotNull();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// role-management UIs list available permissions without a QContext //
|
||||
// instance; the record actions must not break that //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QContext.clear();
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance))
|
||||
.contains("sql_workbench_notebook.read", "sql_workbench_notebook_share.insert", "sql_workbench.hasAccess")
|
||||
.doesNotContain("sql_workbench_execution.delete", "sql_workbench_notebook_version.insert");
|
||||
loginAs(READER);
|
||||
|
||||
QWidgetMetaData widget = (QWidgetMetaData) qInstance.getWidget(SqlWorkbenchWidgetMetaDataProducer.NAME);
|
||||
assertThat(widget.getType()).isEqualTo("SQL_WORKBENCH");
|
||||
|
||||
QAppMetaData app = qInstance.getApp(SqlWorkbenchAppMetaDataProducer.APP_NAME);
|
||||
assertThat(app.getWidgets()).containsExactlyElementsOf(SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames());
|
||||
assertThat(app.getWidgets()).allSatisfy(name -> assertThat(qInstance.getWidget(name)).isNotNull());
|
||||
assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("sql_workbench");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// nav: the Runner (a child app showing the notebook widget), then the //
|
||||
// main tables only; the qbit's processes are record actions, never //
|
||||
// nav children //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly("sqlWorkbenchRunner", "sql_workbench_notebook", "sql_workbench_execution");
|
||||
QAppMetaData runner = qInstance.getApp(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME);
|
||||
assertThat(runner.getLabel()).isEqualTo("Runner");
|
||||
assertThat(runner.getWidgets()).containsExactly(SqlWorkbenchWidgetMetaDataProducer.NAME);
|
||||
assertThat(runner.getChildren()).isNullOrEmpty();
|
||||
assertThat(runner.getPermissionRules().getPermissionBaseName()).isEqualTo("sql_workbench");
|
||||
assertThat(app.getChildren()).noneMatch(child -> child instanceof QProcessMetaData);
|
||||
|
||||
assertThat(QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences()).isNotEmpty();
|
||||
assertThat(SqlWorkbenchRuntime.get(qInstance)).isNotNull();
|
||||
assertThat(new SqlWorkbenchWidgetRenderer().render(null).getWidgetData().getType()).isEqualTo("SQL_WORKBENCH");
|
||||
assertThat(SqlWorkbenchQBitProducer.getVersion()).isNotBlank();
|
||||
assertThat(SqlWorkbenchQBitProducer.isJavalinAvailable()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNamespacePrefixAndReproduce() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(defineH2Backend("other"));
|
||||
SqlWorkbenchQBitConfig prefixed = new SqlWorkbenchQBitConfig().withBackendName("other").withRegisterRoutes(false);
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(prefixed).withNamespace("ns").produce(instance);
|
||||
|
||||
assertThat(instance.getTable("ns_sql_workbench_notebook")).isNotNull();
|
||||
assertThat(instance.getTable("ns_sql_workbench_cell").getField("notebookId").getPossibleValueSourceName()).isEqualTo("ns_sql_workbench_notebook");
|
||||
assertThat(instance.getApp("ns_sqlWorkbench")).isNotNull();
|
||||
assertThat(QJavalinMetaData.of(instance)).isNull();
|
||||
|
||||
SqlWorkbenchRuntime first = prefixed.getRuntime();
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(prefixed).getQBitMetaData();
|
||||
SqlWorkbenchQBitProducer producer = new SqlWorkbenchQBitProducer();
|
||||
producer.setQBitConfig(prefixed);
|
||||
producer.postProduceActions(null, instance);
|
||||
assertThat(prefixed.getRuntime()).isNotSameAs(first);
|
||||
assertThat(producer.getNamespace()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner naming rule (1.0.0-RC.3): table, possible-value source, permission
|
||||
** and security key names are lowercase snake_case prefixed with the qbit's
|
||||
** name; the pre-RC.3 names stay available only as deprecated constants.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void testSnakeCaseNamesAndLegacyConstants() throws Exception
|
||||
{
|
||||
List<String> tableNames = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
assertThat(tableNames).containsExactly("sql_workbench_notebook", "sql_workbench_cell", "sql_workbench_notebook_version",
|
||||
"sql_workbench_result_snapshot", "sql_workbench_notebook_share", "sql_workbench_execution");
|
||||
assertThat(tableNames).allSatisfy(name -> assertThat(name).matches("sql_workbench_[a-z_]+"));
|
||||
assertThat(List.of(SqlWorkbenchNotebook.LEGACY_TABLE_NAME, SqlWorkbenchCell.LEGACY_TABLE_NAME, SqlWorkbenchNotebookVersion.LEGACY_TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.LEGACY_TABLE_NAME, SqlWorkbenchNotebookShare.LEGACY_TABLE_NAME, SqlWorkbenchExecution.LEGACY_TABLE_NAME))
|
||||
.containsExactly("sqlWorkbenchNotebook", "sqlWorkbenchCell", "sqlWorkbenchNotebookVersion", "sqlWorkbenchResultSnapshot",
|
||||
"sqlWorkbenchNotebookShare", "sqlWorkbenchExecution");
|
||||
assertThat(ShareAudienceType.NAME).isEqualTo("sql_workbench_share_audience_type");
|
||||
assertThat(ShareAudienceType.LEGACY_NAME).isEqualTo("sqlWorkbenchShareAudienceType");
|
||||
|
||||
SqlWorkbenchQBitConfig config = new SqlWorkbenchQBitConfig();
|
||||
assertThat(config.getAccessPermissionName()).isEqualTo("sql_workbench.hasAccess");
|
||||
assertThat(config.getWritePermissionName()).isEqualTo("sql_workbench.write");
|
||||
assertThat(config.getSharePermissionName()).isEqualTo("sql_workbench.share");
|
||||
assertThat(config.getAdminPermissionNames()).contains("sql_workbench_admin.hasAccess");
|
||||
assertThat(config.getUserPermissionNames()).allSatisfy(name -> assertThat(name).startsWith("sql_workbench"));
|
||||
assertThat(config.getUserSecurityKeyTypeName()).isEqualTo("sql_workbench_user");
|
||||
assertThat(config.getAudienceSecurityKeyTypeName()).isEqualTo("sql_workbench_audience");
|
||||
assertThat(config.getAllAccessSecurityKeyName()).isEqualTo("sql_workbench_all_access");
|
||||
assertThat(SqlWorkbenchQBitConfig.LEGACY_ACCESS_PERMISSION_BASE_NAME).isEqualTo("sqlWorkbench");
|
||||
assertThat(SqlWorkbenchQBitConfig.LEGACY_ADMIN_PERMISSION_BASE_NAME).isEqualTo("sqlWorkbenchAdmin");
|
||||
assertThat(SqlWorkbenchQBitConfig.LEGACY_USER_SECURITY_KEY_TYPE_NAME).isEqualTo("sqlWorkbenchUser");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// prefixes are normalized, so a prefixed name stays snake_case //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
assertThat(config.withTableNamePrefix("ns").applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)).isEqualTo("ns_sql_workbench_notebook");
|
||||
assertThat(config.withTableNamePrefix("ns_").applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)).isEqualTo("ns_sql_workbench_notebook");
|
||||
assertThat(config.withTableNamePrefix("myNs").applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)).isEqualTo("my_ns_sql_workbench_notebook");
|
||||
assertThat(config.withTableNamePrefix(null).applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)).isEqualTo("sql_workbench_notebook");
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// with no prefix, the default RDBMS backend table name equals the //
|
||||
// QQQ table name: no inference or DDL is needed //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(defineH2Backend("plain"));
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(new SqlWorkbenchQBitConfig().withBackendName("plain").withRegisterRoutes(false)).produce(instance);
|
||||
for(String name : tableNames)
|
||||
{
|
||||
assertThat(instance.getTable(name)).as(name).isNotNull();
|
||||
}
|
||||
assertThat(instance.getPossibleValueSource(ShareAudienceType.NAME)).isNotNull();
|
||||
assertThat(instance.getSecurityKeyType("sql_workbench_user")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWithoutConfig() throws Exception
|
||||
{
|
||||
SqlWorkbenchQBitProducer producer = new SqlWorkbenchQBitProducer();
|
||||
assertThat(producer.isEnabled()).isFalse();
|
||||
assertThatThrownBy(() -> producer.produce(new QInstance())).isInstanceOf(QException.class).hasMessageContaining("no config");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testValidation()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(defineH2Backend("db"));
|
||||
instance.addBackend(new QBackendMetaData().withName("mem").withBackendType(MemoryBackendModule.class));
|
||||
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig(), instance)).anyMatch(e -> e.contains("backendName is required"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("nope"), instance)).anyMatch(e -> e.contains("Backend not found"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db"), instance)).isEmpty();
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withAllowedBackendNames(List.of("mem", "x")), instance))
|
||||
.anyMatch(e -> e.contains("not an RDBMS")).anyMatch(e -> e.contains("backend not found: x"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withReadOnlyCredentials("mem", new SqlWorkbenchCredentials()), instance))
|
||||
.anyMatch(e -> e.contains("non-RDBMS")).anyMatch(e -> e.contains("need a username"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withMaxRows(0).withWritePermissionName("").withDefaultPageSize(5000), instance))
|
||||
.anyMatch(e -> e.contains("maxRows")).anyMatch(e -> e.contains("writePermissionName")).anyMatch(e -> e.contains("defaultPageSize must not exceed"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withMaxAffectedRowsWithoutAcknowledgement(10).withMaxAffectedRows(5), instance))
|
||||
.anyMatch(e -> e.contains("maxAffectedRowsWithoutAcknowledgement must not exceed"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withAdminPermissionBaseName("sql_workbench"), instance))
|
||||
.anyMatch(e -> e.contains("adminPermissionBaseName must differ"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withAdminPermissionBaseName(""), instance))
|
||||
.anyMatch(e -> e.contains("adminPermissionBaseName is required"));
|
||||
|
||||
QInstance bad = new QInstance();
|
||||
bad.addBackend(defineH2Backend("db"));
|
||||
assertThatThrownBy(() -> new SqlWorkbenchQBitProducer().withQBitConfig(new SqlWorkbenchQBitConfig().withBackendName("nope")).produce(bad))
|
||||
.isInstanceOf(QException.class).hasMessageContaining("Backend not found");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConfigDetails()
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = new SqlWorkbenchQBitConfig().withBackendName("db")
|
||||
.withReadOnlyCredentials("db", new SqlWorkbenchCredentials().withUsername("ro").withPassword("secret"))
|
||||
.withWriteCredentials("db", new SqlWorkbenchCredentials().withUsername("rw").withPassword("secret2"));
|
||||
assertThat(config.toString()).doesNotContain("secret").doesNotContain("ro\"").contains("db");
|
||||
assertThat(config.getReadOnlyCredentials().get("db").toString()).doesNotContain("secret").doesNotContain("ro,");
|
||||
assertThat(config.getAccessPermissionName()).isEqualTo("sql_workbench.hasAccess");
|
||||
assertThat(config.getAdminPermissionName()).isEqualTo("sql_workbench_admin.hasAccess");
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// credentials never serialize - jdbcUrl can carry a password //
|
||||
////////////////////////////////////////////////////////////////
|
||||
String credentialsJson = com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(new SqlWorkbenchCredentials()
|
||||
.withUsername("ro").withPassword("secret").withJdbcUrl("jdbc:postgresql://db/app?password=urlsecret"));
|
||||
assertThat(credentialsJson).doesNotContain("secret").doesNotContain("urlsecret").doesNotContain("jdbc:");
|
||||
assertThat(config.getAdminPermissionRules().getPermissionBaseName()).isEqualTo("sql_workbench_admin");
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("db");
|
||||
assertThat(config.applyPrefix("t")).isEqualTo("t");
|
||||
assertThat(config.withTableNamePrefix("p").applyPrefix("t")).isEqualTo("p_t");
|
||||
|
||||
SqlWorkbenchQBitConfig empty = new SqlWorkbenchQBitConfig();
|
||||
empty.setReadOnlyCredentials(null);
|
||||
empty.setWriteCredentials(null);
|
||||
empty.withReadOnlyCredentials("a", new SqlWorkbenchCredentials()).withWriteCredentials("a", new SqlWorkbenchCredentials());
|
||||
assertThat(empty.getReadOnlyCredentials()).containsKey("a");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> errors(SqlWorkbenchQBitConfig config, QInstance instance)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(instance, errors);
|
||||
return (errors);
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user