Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae54611acf
|
||
|
|
7304aaf5dd | ||
|
|
e783261d2d
|
||
|
|
ee2fd12aa1 | ||
|
|
60d8776564 | ||
|
|
b281741d2e | ||
|
|
47a3e62a45
|
||
|
|
7e9017c376
|
||
|
|
6da4fb400d
|
||
|
|
7c2d0610a5
|
||
|
|
268499aa74 | ||
|
|
d864eb996d | ||
|
|
c59b2ae72f | ||
|
|
103ef543d1
|
||
|
|
84a7033ae4
|
||
|
|
316a5d6f71 | ||
|
|
236fa901cf
|
||
|
|
5600496cce | ||
|
|
b0b027a9aa
|
||
|
|
4d5139cab6 | ||
|
|
3ca94f7da5 | ||
|
|
cfb176e3ac | ||
|
|
c858bcb720
|
||
|
|
e156a710e3
|
||
|
|
9ca854e59a | ||
|
|
6029174344 | ||
|
|
1ffc663ba5 | ||
|
|
777eb3b95b | ||
|
|
7f21e46a2f | ||
|
|
dbdb5eb113 | ||
|
|
dd4bd63b2b
|
||
|
|
c36a0e8c94
|
||
|
|
72647623fa | ||
|
|
f7b5ffd569 | ||
|
|
f42f76c59b | ||
|
|
b3913fca7f | ||
|
|
68f559dfc8
|
||
|
|
5be0693255 | ||
|
|
18651d6712
|
||
|
|
6f2626b04f
|
||
|
|
51c5a06859
|
||
|
|
c78737c359 | ||
|
|
ad8f9001a6
|
||
|
|
8346a5fe27
|
||
|
|
e0d9312aff
|
||
|
|
663c209f4e
|
||
|
|
f823c9a50b
|
||
|
|
ddd85ebe0d
|
||
|
|
6c90ba12c0 | ||
|
|
ea6e9ffec9
|
||
|
|
4f1e2305e1 | ||
|
|
c2e754b146
|
||
|
|
731225813d |
No files matched your search
+3
-14
@@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
@@ -22,7 +22,7 @@ workflows:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
@@ -57,15 +57,4 @@ workflows:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
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\/.*/]
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
@@ -0,0 +1,20 @@
|
||||
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.cmd text eol=crlf
|
||||
mvnw text eol=lf
|
||||
@@ -0,0 +1,25 @@
|
||||
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
|
||||
# major updates stay separate so each gets its own review. At most three version-update PRs per
|
||||
# ecosystem are open at once. Security updates are enabled in the repository settings and are
|
||||
# not limited by this schedule.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: maven
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
maven-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ['*']
|
||||
@@ -0,0 +1,25 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
@@ -4,7 +4,7 @@ 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. Design: `docs/DESIGN.md`.
|
||||
**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
|
||||
|
||||
@@ -18,13 +18,13 @@ CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo ga
|
||||
|
||||
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 access, `writer-*` gets access plus write plus share, anything else gets nothing.
|
||||
- **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.
|
||||
- `postProduceActions` builds `SqlWorkbenchRuntime` and calls `api.SqlWorkbenchEndpoints.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.
|
||||
@@ -37,15 +37,22 @@ How tests are split:
|
||||
- `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, visibility, versions in one transaction, snapshots).
|
||||
- `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 5 tables, the notebook PVS, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
|
||||
- `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), `SqlWorkbenchJsonSpec` and concrete endpoint specs (the UI API extension under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchEndpoints`.
|
||||
- `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
|
||||
@@ -58,7 +65,7 @@ How tests are split:
|
||||
## 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.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- 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.6` ahead of it (RC.6 brings the UI API extension 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.
|
||||
|
||||
|
||||
@@ -2,6 +2,65 @@
|
||||
|
||||
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
|
||||
|
||||
## [1.0.0-RC.4] - Unreleased
|
||||
|
||||
### Added
|
||||
- Host-neutral in-app help bundle `help/qbit-sql-workbench.yaml`, registered with QQQ help bundles, with live references and coverage checks for workbench metadata and generated table actions. Replaces the earlier minimal bundle while retaining inline app/table help for hosts without help storage. Requires QQQ `4.1.0-RC.7`.
|
||||
- Table help: the SQL Workbench app and every `sql_workbench_*` table carry help saying what the table is for and how its rows get created (`help/qbit-sql-workbench.yaml`, QQQ help-bundle format, applied by `SqlWorkbenchHelp`; host help is never overridden). `SqlWorkbenchTablesGuardrailTest` fails when a table is unused by runtime code, unexplained, or has no such help. All six tables are user content or logs and start empty by design; nothing is seeded.
|
||||
- `auditRetentionDays` config (default null: keep forever, no change for existing hosts): when set, execution audit rows older than that many days are deleted, in the background in bounded batches, at most once an hour per process (first pass 5 minutes after start), triggered as a run is audited; rows still RUNNING are kept. DESIGN.md already described this setting; it did not exist before.
|
||||
- OpenAPI coverage for all 21 SQL Workbench UI API operations, including streaming CSV/JSON/XLSX export. The `sql-workbench` extension preserves `/qqq/v1/sql-workbench`, existing request/response shapes, permission and ownership checks, exact decimal parameters, execution limits and audit. The generated fragment is in `src/main/resources/openapi/qqq-v1/sql-workbench.yaml`; strict-server security tests and a fragment contract test guard it.
|
||||
- **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
|
||||
- Require QQQ `4.1.0-RC.6` for the UI API extension contract. Replace the raw Javalin route provider with endpoint specs registered by the qbit producer. `registerRoutes=false` still disables HTTP registration. No existing operation wire changes.
|
||||
- **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 `_`.
|
||||
|
||||
### Fixed
|
||||
- **Pinning a result works with QQQ core permission enforcement on.** `getUserPermissionNames()` now includes `sql_workbench_result_snapshot.insert`: pinning is a top-level insert, which core enforcement checks, so hosts no longer need to grant it by hand. Record locks still limit snapshots to the user's own notebooks.
|
||||
|
||||
### Deprecated
|
||||
- `LEGACY_TABLE_NAME` on each entity, `ShareAudienceType.LEGACY_NAME` and `SqlWorkbenchQBitConfig.LEGACY_*` hold the pre-RC.4 names, for migrating stored references only.
|
||||
|
||||
### Upgrade from RC.2 (or RC.3, a copy of 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.3] - 2026-10-12
|
||||
|
||||
**Do not use.** 1.0.0-RC.3 on Maven Central is an accidental republish of the RC.2 code by a tag build; do not use it. The next real candidate is 1.0.0-RC.4. (Cause: QRun-IO/qqq-orb#20; the `publish_release` workflow now runs on GA tags only.)
|
||||
|
||||
## [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).
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code when working with code in this reposi
|
||||
|
||||
## 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. Design: `docs/DESIGN.md`.
|
||||
**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
|
||||
|
||||
@@ -18,7 +18,7 @@ CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo ga
|
||||
|
||||
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 access, `writer-*` gets access plus write plus share, anything else gets nothing.
|
||||
- **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
|
||||
|
||||
@@ -37,15 +37,22 @@ How tests are split:
|
||||
- `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, visibility, versions in one transaction, snapshots).
|
||||
- `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 5 tables, the notebook PVS, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
|
||||
- `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
|
||||
@@ -58,7 +65,7 @@ How tests are split:
|
||||
## 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.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
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 lives in the [Next UI](https://github.com/QRun-IO/qqq-frontend-next) as a `SQL_WORKBENCH` widget on the qbit's app page.
|
||||
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.
|
||||
|
||||
- **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 `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.
|
||||
- **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.
|
||||
|
||||
> **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.
|
||||
|
||||
@@ -15,7 +16,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.2`).
|
||||
- Java 21 and QQQ `4.1.0-RC.7` or later (UI API extensions and help bundles).
|
||||
- `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.
|
||||
|
||||
@@ -25,13 +26,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.1</version>
|
||||
<version>1.0.0-RC.2</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
```java
|
||||
SqlWorkbenchQBitConfig config = new SqlWorkbenchQBitConfig()
|
||||
.withBackendName("postgres") // where the qbit's 5 tables live
|
||||
.withBackendName("postgres") // where the qbit's 6 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
|
||||
@@ -42,17 +43,57 @@ new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
|
||||
The producer adds the following to the instance:
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **All 21 `/qqq/v1/sql-workbench/*` operations**, registered as the `sql-workbench` UI API extension. The producer preserves the established prefix; strict UI API route checking is supported. The generated [OpenAPI fragment](src/main/resources/openapi/qqq-v1/sql-workbench.yaml) covers JSON and streaming exports. See [the contract and operation catalog](docs/UI-API.md).
|
||||
|
||||
To move the app under another app (for example an Admin app), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing.
|
||||
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>`.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-sql-workbench.yaml](src/main/resources/help/qbit-sql-workbench.yaml), a `QBIT` bundle with summaries, Markdown help and live metadata references. Hosts with `helpContent` load help and call `HelpBundles.syncOnStartup(qInstance)` after defining metadata. Registration itself needs no help table; app/table inline help remains available without one.
|
||||
|
||||
To override wording, register your own resource with `HelpBundles.register(qInstance, "help/my-host.yaml")`, set `layer: HOST`, and use the same entry `key` and `role` (default `ALL_SCREENS`). HOST text takes precedence over QBIT text; user-edited help is preserved by sync. Keep the bundle's literal version aligned with the POM version when releasing.
|
||||
|
||||
`SqlWorkbenchHelpBundleTest` uses `HelpBundleAssertions` to check ownership coverage, keys, summary lengths and live references, including generated table actions. Run it with `./mvnw -q test -Dtest=SqlWorkbenchHelpBundleTest`; it also verifies registration without a help table.
|
||||
|
||||
## 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.
|
||||
|
||||
## Storage
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
The DDL uses snake_case names: `sql_workbench_notebook`, `owner_user_id`, and so on. Match them with a table customizer:
|
||||
|
||||
@@ -68,25 +109,76 @@ public class SnakeCaseCustomizer implements MetaDataCustomizerInterface<QTableMe
|
||||
}
|
||||
```
|
||||
|
||||
## Starter defaults and retention
|
||||
|
||||
All six tables are user content (notebooks, cells, shares, pinned snapshots) or logs (versions, the execution audit), so none is seeded: they start empty, and each table's help (`help/qbit-sql-workbench.yaml`, shown on the table screen) says how its rows get created. No host include or action is needed.
|
||||
|
||||
Retention: `notebookVersionRetention` (default 100) prunes the oldest versions of a notebook on save. `auditRetentionDays` (default null: keep forever) deletes execution audit rows older than that many days, in the background in bounded batches, at most once an hour per process, triggered as a run is audited.
|
||||
|
||||
## 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.
|
||||
Everything is denied by default. Two standard QQQ layers apply:
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `sqlWorkbench.hasAccess` | The app, widget, and every workbench endpoint: read-only SQL, EXPLAIN, schema, your notebooks and the shared library, your history |
|
||||
| `sqlWorkbench.write` | Write mode (preview and commit) and EXPLAIN ANALYZE |
|
||||
| `sqlWorkbench.share` | Publishing your notebooks to the shared library |
|
||||
| `sqlWorkbenchAdmin.hasAccess` | The qbit's five tables in the generic QQQ table UI / API (`/qqq/v1/table/...`): **every** user's notebooks (personal ones included), cells, versions, pinned result snapshots (real query data), and the full execution audit |
|
||||
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.
|
||||
|
||||
The names are configurable (`accessPermissionBaseName`, `writePermissionName`, `sharePermissionName`, `adminPermissionBaseName`).
|
||||
| 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/insert/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. |
|
||||
|
||||
Grant `sqlWorkbenchAdmin.hasAccess` only to administrators or auditors. Ordinary workbench users do not need it: the workbench endpoints check ownership, visibility, and the share permission themselves, then read and write the tables as the system user. Without the admin permission a user cannot reach another user's personal notebook or audit row through the generic table API. Cells, versions, snapshots, and the audit are read-only even for admins (insert, edit, and delete are disabled; only the qbit writes them).
|
||||
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.
|
||||
|
||||
`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.
|
||||
**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 |
|
||||
|
||||
`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.
|
||||
@@ -146,6 +238,32 @@ 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.0-RC.6` or later (qqq#1128: UI API extension specs). 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 (or RC.3, a copy of 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
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Upgrading
|
||||
|
||||
## Table help and `auditRetentionDays` (unreleased)
|
||||
|
||||
No action needed. The qbit now fills empty `help` slots on its app and tables (help a host already set is kept). The new `auditRetentionDays` config defaults to null, which keeps every audit row as before; set it to bound the size of `sql_workbench_execution`. `notebookVersionRetention` (default 100 per notebook) was already enforced. There is no DDL change and no seed.
|
||||
|
||||
## To 1.0.0-RC.4: snake_case names (metadata only)
|
||||
|
||||
The rename shipped in 1.0.0-RC.4. 1.0.0-RC.3 on Maven Central is an accidental republish of the RC.2 code (see CHANGELOG.md), so upgrading from RC.3 is the same as upgrading from RC.2.
|
||||
|
||||
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.4 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, RC.3) | From RC.4 |
|
||||
|---|---|---|
|
||||
| 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).
|
||||
+59
-31
@@ -40,9 +40,9 @@ 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 sqlWorkbench.hasAccess)│
|
||||
│ case 'SQL_WORKBENCH' │ │ by sql_workbench.hasAccess)│
|
||||
│ └ SqlWorkbenchWidget (lazy) │ │ │
|
||||
│ ├ NotebookList │ /qqq/v1/ │ SqlWorkbenchRouteProvider │
|
||||
│ ├ NotebookList │ /qqq/v1/ │ SQL Workbench endpoint specs │
|
||||
│ ├ NotebookEditor │ sql-workbench/*│ └ SqlWorkbenchApi (session, perms, JSON) │
|
||||
│ │ ├ SqlCell (CM6) │◄───────────────►│ ├ SqlExecutionEngine ──► ConnectionMgr │
|
||||
│ │ ├ MarkdownCell │ │ │ ├ SqlClassifier / NamedParameterSql│
|
||||
@@ -64,7 +64,8 @@ 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 5 tables, the `sqlWorkbenchWidget` widget (type `SQL_WORKBENCH`), and the `sqlWorkbench` app (widget plus notebook and audit tables). No processes (owner rule). |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
@@ -72,12 +73,12 @@ SQL Workbench is a super-user SQL notebook for any QQQ application. Users write
|
||||
| `explain/*` | `ExplainService` (per-dialect EXPLAIN syntax) and `PlanParser`, which normalizes Postgres JSON, MySQL JSON, SQLite `EXPLAIN QUERY PLAN` rows, and H2 text into one `PlanNode` tree. |
|
||||
| `notebook/NotebookService` | Notebook CRUD, visibility, versions, and snapshots through QQQ actions on the qbit tables. |
|
||||
| `api/SqlWorkbenchApi` | Transport-agnostic API: map in, map out. Checks permissions and throws QQQ exceptions. Unit-testable without HTTP. |
|
||||
| `api/SqlWorkbenchRouteProvider` | `QJavalinRouteProviderInterface`. Sets up the session (`ExecutorSessionUtils.setupSession`), calls the API, writes JSON, and maps errors through `QJavalinUtils.handleException`. Clears the context in `finally`. |
|
||||
| `api/SqlWorkbenchJavalinMetaDataProducer` | Adds the route provider to `QJavalinMetaData` once (the ESB pattern). Called from `postProduceActions` when the class is loadable. |
|
||||
| `api/SqlWorkbenchEndpoints` and `*Spec` | One `MiddlewareSpecExtension`, `sql-workbench`, at `/sql-workbench`. Twenty JSON specs extend `AbstractJsonEndpointSpec`; export extends `AbstractEndpointSpec` directly. Core sets up and clears the session. The shared JSON transport checks access before parsing and keeps exact decimal parameters and 409 duplicate-execution errors. Existing services retain permission checks, limits and audit. See [UI API contract](UI-API.md). |
|
||||
| `api/SqlWorkbenchSchemas` | Versioned request and response component schemas, declared once by the capabilities spec for the complete extension. The committed fragment is checked by `SqlWorkbenchApiContractTest`. |
|
||||
|
||||
### 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 `sqlWorkbench.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 `sql_workbench.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
|
||||
@@ -104,9 +105,20 @@ Components (`src/components/sql-workbench/`):
|
||||
|
||||
## 3. Data model
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
### `sqlWorkbenchNotebook`
|
||||
```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`
|
||||
| field | type | notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
@@ -114,34 +126,48 @@ Five tables on the host-chosen backend (`config.backendName`). Names can be pref
|
||||
| description | TEXT | |
|
||||
| ownerUserId | STRING(255) | `QSession.user.idReference`, required |
|
||||
| ownerName | STRING(255) | display only |
|
||||
| visibility | STRING(16) | `PERSONAL` (default) or `SHARED` |
|
||||
| visibility | STRING(16) | `PERSONAL` or `SHARED`; derived from the shares (`SHARED` while an `ALL` share exists), not editable |
|
||||
| defaultBackendName | STRING(100) | |
|
||||
| parametersJson | TEXT | `[{"name","type","defaultValue","label"}]` |
|
||||
| currentVersion | INTEGER | incremented on every save |
|
||||
| currentVersion | INTEGER | incremented on every content change, from the notebook UI or the tables |
|
||||
| createDate, modifyDate | DATE_TIME | |
|
||||
|
||||
### `sqlWorkbenchCell`
|
||||
### `sql_workbench_cell`
|
||||
| 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 | |
|
||||
| sortOrder | INTEGER | cells added through the table go last |
|
||||
| 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 | |
|
||||
|
||||
### `sqlWorkbenchNotebookVersion`
|
||||
Append-only history. Each save writes the full notebook plus cells as JSON.
|
||||
### `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).
|
||||
`id, notebookId, versionNumber, contentJson (TEXT), savedByUserId, savedByName, comment, createDate`. Unique key: `(notebookId, versionNumber)`. Retention: `notebookVersionRetention` (default 100 per notebook, oldest pruned on save).
|
||||
|
||||
### `sqlWorkbenchResultSnapshot`
|
||||
Pinned results.
|
||||
### `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.
|
||||
`id, notebookId, cellKey, executionUuid, label, backendName, sqlText (TEXT), resultJson (TEXT: columns plus up to snapshotMaxRows rows), rowCount, truncated, createdByUserId, createDate`.
|
||||
|
||||
### `sqlWorkbenchExecution` (audit, append-only)
|
||||
### `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)
|
||||
| field | type | notes |
|
||||
|---|---|---|
|
||||
| id | INTEGER PK | |
|
||||
@@ -164,14 +190,14 @@ Pinned results.
|
||||
| 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 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).
|
||||
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; when set, rows whose run started earlier than that many days ago are deleted, in the background in bounded batches, at most once an hour per process (first pass 5 minutes after start), triggered as a run is audited; rows still RUNNING are kept).
|
||||
|
||||
---
|
||||
|
||||
## 4. Execution engine
|
||||
|
||||
### 4.1 Pipeline (every run)
|
||||
1. **Authorize.** The session must have `sqlWorkbench.hasAccess` (403 otherwise). Write and EXPLAIN ANALYZE also need `sqlWorkbench.write` and `config.writeModeEnabled`.
|
||||
1. **Authorize.** The session must have `sql_workbench.hasAccess` (403 otherwise). Write and EXPLAIN ANALYZE also need `sql_workbench.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).
|
||||
@@ -207,7 +233,7 @@ There is no session-level `SET` leakage: user SQL can never run `SET` / `RESET`
|
||||
> **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 `sqlWorkbench.write`. It is a two-step protocol:
|
||||
Write mode needs `sql_workbench.write`. It is a two-step protocol:
|
||||
|
||||
1. **Preview**: `POST /execute {mode: "WRITE"}` with no `confirmToken`.
|
||||
- Classify the statements and reject `FORBIDDEN` ones.
|
||||
@@ -276,7 +302,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 `sqlWorkbench.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 `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.
|
||||
|
||||
```yaml
|
||||
GET /capabilities
|
||||
@@ -308,7 +334,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 sqlWorkbench.share)
|
||||
POST /notebooks/{id}/publish body: { shared: bool } (needs sql_workbench.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 }
|
||||
@@ -323,7 +349,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 `sqlWorkbench.share` **and** ownership.
|
||||
- Publishing or unpublishing needs `sql_workbench.share` **and** ownership.
|
||||
- Anyone with access can duplicate a readable notebook.
|
||||
|
||||
---
|
||||
@@ -332,7 +358,7 @@ Visibility rules:
|
||||
|
||||
| Concern | Control |
|
||||
|---|---|
|
||||
| Who | Deny by default. Every endpoint needs `sqlWorkbench.hasAccess` (the app and widget permission base name `sqlWorkbench`, at `HAS_ACCESS_PERMISSION`). Write mode and EXPLAIN ANALYZE need `sqlWorkbench.write`. Publishing to the library needs `sqlWorkbench.share`. The qbit's tables use a separate base name, `sqlWorkbenchAdmin` (`sqlWorkbenchAdmin.hasAccess`), so only administrators reach them through the generic QQQ table UI / API. The names are configurable. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -340,7 +366,9 @@ 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. |
|
||||
| Personal vs shared | A personal notebook is visible only to its owner. The five tables need `sqlWorkbenchAdmin.hasAccess`, which ordinary workbench users do not hold, so `/qqq/v1/table/...` cannot read or write other users' notebooks, cells, versions, pinned snapshots, or audit rows. `NotebookService` and the history endpoint check owner / visibility / share permission in code, then read and write as the system user (`SystemUser`), as `ExecutionAuditor` does for the audit. Cells, versions, snapshots, and the audit have insert / edit / delete disabled; only the qbit writes them. Holders of the admin permission see every user's notebooks and the full audit. |
|
||||
| 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). |
|
||||
|
||||
---
|
||||
|
||||
@@ -358,9 +386,9 @@ 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` | `sqlWorkbench.write` | |
|
||||
| `sharePermissionName` | `sqlWorkbench.share` | |
|
||||
| `adminPermissionBaseName` | `sqlWorkbenchAdmin` | `<base>.hasAccess` guards the qbit's tables in the generic table UI / API; must differ from `accessPermissionBaseName` |
|
||||
| `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` |
|
||||
| `statementTimeoutSeconds` | 30 | read-only and EXPLAIN |
|
||||
| `writeStatementTimeoutSeconds` | 60 | preview and commit |
|
||||
| `maxRows` | 10000 | rows kept per result |
|
||||
@@ -404,7 +432,7 @@ Visibility rules:
|
||||
│ │ + SQL cell + Markdown cell │
|
||||
└────────────────────────┴───────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Write mode (user has sqlWorkbench.write; toggled per cell):
|
||||
Write mode (user has sql_workbench.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 │
|
||||
@@ -472,7 +500,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.** `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).
|
||||
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).
|
||||
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`).
|
||||
@@ -0,0 +1,93 @@
|
||||
# SQL Workbench UI API contract
|
||||
|
||||
All 21 operations are registered by `SqlWorkbenchQBitProducer` through
|
||||
`SqlWorkbenchEndpoints` as one `MiddlewareSpecExtension` named `sql-workbench`,
|
||||
version `1.0.0`, with the established `/sql-workbench` prefix. Full URLs remain
|
||||
`/qqq/v1/sql-workbench/...`. QQQ `4.1.0-RC.6` is the minimum core version.
|
||||
`registerRoutes=false` disables extension registration; middleware remains optional.
|
||||
|
||||
The producer registers no raw routes. Twenty JSON operations extend
|
||||
`AbstractJsonEndpointSpec` through `SqlWorkbenchJsonSpec`; `ExportResultSpec`
|
||||
extends `AbstractEndpointSpec` directly and streams CSV, JSON or XLSX with the
|
||||
existing attachment filename and `Cache-Control: no-store` headers.
|
||||
|
||||
The checked-in fragment is
|
||||
[`src/main/resources/openapi/qqq-v1/sql-workbench.yaml`](../src/main/resources/openapi/qqq-v1/sql-workbench.yaml).
|
||||
It is also included in a host's `/qqq/v1/openapi.yaml` and `.json`, and the
|
||||
extension appears in `/qqq/v1/version`. All component names begin with
|
||||
`SqlWorkbench`. Schemas explicitly describe the existing map payloads rather
|
||||
than changing their serialization. SQL results include nullable scalar values,
|
||||
binary previews and nested arrays; execute responses vary by execution status.
|
||||
|
||||
## Compatibility and security
|
||||
|
||||
No existing operation paths, methods, request or response shapes, status codes,
|
||||
or export headers change. Next UI can vendor the fragment without adapting its
|
||||
existing calls. New metadata consists of the extension/version advertisement,
|
||||
OpenAPI schemas and stable operation identifiers.
|
||||
|
||||
Core authenticates the caller before each spec runs and clears `QContext` after
|
||||
the request. Workbench access is checked before parsing bodies, path integers or
|
||||
export formats. JSON object parsing retains `BigDecimal` precision, JSON-only
|
||||
content-type checks, empty/null-body behavior, and existing error messages.
|
||||
Duplicate execution UUIDs still return 409. SQL failures remain HTTP 200 with a
|
||||
`FAILED`, `TIMED_OUT` or `CANCELLED` execution status.
|
||||
|
||||
Specs call the same `SqlWorkbenchApi`, notebook service and execution engine.
|
||||
Write/EXPLAIN ANALYZE gates, sharing permissions, caller record locks, notebook
|
||||
ownership, cached-result ownership, cancellation ownership, per-user concurrency
|
||||
limits, audit-before-SQL and deferred audit updates remain in place. Client IPs
|
||||
continue into execution audits. No request bodies or result data are logged by
|
||||
the transport.
|
||||
|
||||
## Operations
|
||||
|
||||
| Method | Path (after `/qqq/v1/sql-workbench`) | operationId |
|
||||
|---|---|---|
|
||||
| GET | `/capabilities` | `sqlWorkbenchGetCapabilities` |
|
||||
| POST | `/execute` | `sqlWorkbenchExecute` |
|
||||
| POST | `/executions/{uuid}/cancel` | `sqlWorkbenchCancelExecution` |
|
||||
| GET | `/executions/{uuid}/export` | `sqlWorkbenchExportResult` |
|
||||
| GET | `/executions/{uuid}/rows` | `sqlWorkbenchGetRows` |
|
||||
| POST | `/explain` | `sqlWorkbenchExplain` |
|
||||
| GET | `/history` | `sqlWorkbenchGetHistory` |
|
||||
| GET | `/notebooks` | `sqlWorkbenchListNotebooks` |
|
||||
| POST | `/notebooks` | `sqlWorkbenchCreateNotebook` |
|
||||
| DELETE | `/notebooks/{id}` | `sqlWorkbenchDeleteNotebook` |
|
||||
| GET | `/notebooks/{id}` | `sqlWorkbenchGetNotebook` |
|
||||
| PUT | `/notebooks/{id}` | `sqlWorkbenchUpdateNotebook` |
|
||||
| POST | `/notebooks/{id}/duplicate` | `sqlWorkbenchDuplicateNotebook` |
|
||||
| POST | `/notebooks/{id}/publish` | `sqlWorkbenchPublishNotebook` |
|
||||
| GET | `/notebooks/{id}/snapshots` | `sqlWorkbenchListSnapshots` |
|
||||
| POST | `/notebooks/{id}/snapshots` | `sqlWorkbenchCreateSnapshot` |
|
||||
| DELETE | `/notebooks/{id}/snapshots/{snapshotId}` | `sqlWorkbenchDeleteSnapshot` |
|
||||
| GET | `/notebooks/{id}/versions` | `sqlWorkbenchListVersions` |
|
||||
| GET | `/notebooks/{id}/versions/{n}` | `sqlWorkbenchGetVersion` |
|
||||
| POST | `/notebooks/{id}/versions/{n}/restore` | `sqlWorkbenchRestoreVersion` |
|
||||
| GET | `/schema/{backendName}` | `sqlWorkbenchGetSchema` |
|
||||
|
||||
## Verification and regeneration
|
||||
|
||||
`SqlWorkbenchApiContractTest` regenerates the fragment in memory and checks it
|
||||
against the checked-in file using core's deterministic `UiApiContract` tooling.
|
||||
It also checks the namespace, secured specs, prefix, media types and 21 unique
|
||||
operation IDs. Intentional contract changes can regenerate the file with:
|
||||
|
||||
```sh
|
||||
./mvnw -B test -Dtest=SqlWorkbenchApiContractTest -DsqlWorkbench.openapi.update=true
|
||||
```
|
||||
|
||||
Before RC.6 is released, local verification uses the separately installed core:
|
||||
|
||||
```sh
|
||||
./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
|
||||
```
|
||||
|
||||
The POM must continue to name `4.1.0-RC.6`; the local override is never a release
|
||||
dependency. On PowerShell, quote each `-D...` argument and use `mvnw.cmd`.
|
||||
|
||||
`SqlWorkbenchEndpointSpecTest` runs real HTTP through
|
||||
`QApplicationJavalinServer.withStrictUiApiRoutes(true)`. It preserves the former
|
||||
route-provider tests and adds 401/403 coverage for every operation, mutation
|
||||
denials, execution limits, audited rejection, exact decimals, SQL failures and
|
||||
all three exports. Engine tests continue to verify audit and transaction safety.
|
||||
@@ -74,6 +74,35 @@ CREATE TABLE IF NOT EXISTS sql_workbench_result_snapshot (
|
||||
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)
|
||||
);
|
||||
-- the notebook page lists its snapshots, and deleting a notebook deletes them, by notebook_id
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_result_snapshot_notebook ON sql_workbench_result_snapshot (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.
|
||||
@@ -106,3 +135,5 @@ CREATE TABLE IF NOT EXISTS sql_workbench_execution (
|
||||
);
|
||||
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);
|
||||
-- the notebook page's "Runs" list joins executions by notebook_id
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_execution_notebook ON sql_workbench_execution (notebook_id);
|
||||
@@ -18,6 +18,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>1.0.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -29,7 +31,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -154,6 +156,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>sql-workbench.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
@@ -161,6 +164,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>sql-workbench.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
@@ -250,16 +254,17 @@
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer, runtime services, Javalin 7 -->
|
||||
<!-- routes). 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.2 -->
|
||||
<!-- ahead of the parent's 4.0.0 BOM (same as qbit-quick-search, ADR-0007). -->
|
||||
<!-- specs and help bundles require 4.1.0-RC.7). 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.7 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.2</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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"))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
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.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
@@ -41,9 +47,13 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaDat
|
||||
** excludedBackendNames over the instance's RDBMS backends, optional
|
||||
** readOnlyCredentials / writeCredentials per backend, and
|
||||
** useBackendReadOnlyReplica. Permissions: accessPermissionBaseName
|
||||
** (=> "sqlWorkbench.hasAccess"), writePermissionName, sharePermissionName,
|
||||
** and adminPermissionBaseName (=> "sqlWorkbenchAdmin.hasAccess"), which
|
||||
** alone grants the generic QQQ table UI / API over the qbit's tables.
|
||||
** (=> "sql_workbench.hasAccess"), writePermissionName, sharePermissionName,
|
||||
** and adminPermissionBaseName (=> "sql_workbench_admin.hasAccess", which
|
||||
** grants the all-access security key: every user's notebooks and the whole
|
||||
** audit). The tables use QQQ's per-table read / insert / edit / delete
|
||||
** permissions; getUserPermissionNames, getSharePermissionNames and
|
||||
** getAdminPermissionNames list what to grant per role, and
|
||||
** SqlWorkbenchSecurity.applyToSession puts the security keys in a session.
|
||||
** Limits: timeouts, rows, bytes, statement counts, row guard, caches.
|
||||
**
|
||||
** validate() is list-based and never throws; the producer fails production
|
||||
@@ -51,10 +61,34 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaDat
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final String DEFAULT_ACCESS_PERMISSION_BASE_NAME = "sqlWorkbench";
|
||||
public static final String DEFAULT_WRITE_PERMISSION_NAME = "sqlWorkbench.write";
|
||||
public static final String DEFAULT_SHARE_PERMISSION_NAME = "sqlWorkbench.share";
|
||||
public static final String DEFAULT_ADMIN_PERMISSION_BASE_NAME = "sqlWorkbenchAdmin";
|
||||
public static final String DEFAULT_ACCESS_PERMISSION_BASE_NAME = "sql_workbench";
|
||||
public static final String DEFAULT_WRITE_PERMISSION_NAME = "sql_workbench.write";
|
||||
public static final String DEFAULT_SHARE_PERMISSION_NAME = "sql_workbench.share";
|
||||
public static final String DEFAULT_ADMIN_PERMISSION_BASE_NAME = "sql_workbench_admin";
|
||||
|
||||
public static final String USER_SECURITY_KEY_TYPE_NAME = "sql_workbench_user";
|
||||
public static final String AUDIENCE_SECURITY_KEY_TYPE_NAME = "sql_workbench_audience";
|
||||
public static final String ALL_ACCESS_SECURITY_KEY_NAME = "sql_workbench_all_access";
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// names before 1.0.0-RC.4, when the owner rule (lowercase snake_case, //
|
||||
// prefixed with the qbit name) was applied; kept only so hosts can //
|
||||
// migrate stored references (see UPGRADE.md) //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_ACCESS_PERMISSION_BASE_NAME = "sqlWorkbench";
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_WRITE_PERMISSION_NAME = "sqlWorkbench.write";
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_SHARE_PERMISSION_NAME = "sqlWorkbench.share";
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_ADMIN_PERMISSION_BASE_NAME = "sqlWorkbenchAdmin";
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_USER_SECURITY_KEY_TYPE_NAME = "sqlWorkbenchUser";
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_AUDIENCE_SECURITY_KEY_TYPE_NAME = "sqlWorkbenchAudience";
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_ALL_ACCESS_SECURITY_KEY_NAME = "sqlWorkbenchAllAccess";
|
||||
|
||||
private String backendName;
|
||||
private String tableNamePrefix;
|
||||
@@ -88,6 +122,7 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
private Integer maxSchemaTables = 2000;
|
||||
private Integer snapshotMaxRows = 1000;
|
||||
private Integer notebookVersionRetention = 100;
|
||||
private Integer auditRetentionDays = null;
|
||||
private Boolean registerRoutes = true;
|
||||
|
||||
private transient MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
@@ -181,6 +216,10 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
requirePositive(errors, "maxSchemaTables", maxSchemaTables);
|
||||
requirePositive(errors, "snapshotMaxRows", snapshotMaxRows);
|
||||
requirePositive(errors, "notebookVersionRetention", notebookVersionRetention);
|
||||
if(auditRetentionDays != null)
|
||||
{
|
||||
requirePositive(errors, "auditRetentionDays", auditRetentionDays);
|
||||
}
|
||||
|
||||
if(defaultPageSize != null && maxPageSize != null && defaultPageSize > maxPageSize)
|
||||
{
|
||||
@@ -218,11 +257,29 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the table name prefix, if any.
|
||||
** Apply the table name prefix, if any. The prefix is normalized to
|
||||
** lowercase snake_case and joined with one "_", so a prefix of "ns",
|
||||
** "ns_" or "myNs" gives "ns_sql_workbench_notebook" or
|
||||
** "my_ns_sql_workbench_notebook".
|
||||
*******************************************************************************/
|
||||
public String applyPrefix(String name)
|
||||
{
|
||||
return (StringUtils.hasContent(tableNamePrefix) ? tableNamePrefix + "_" + name : name);
|
||||
if(!StringUtils.hasContent(tableNamePrefix))
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
return (normalizePrefix(tableNamePrefix) + "_" + name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lowercase snake_case form of a prefix, without leading or trailing "_".
|
||||
*******************************************************************************/
|
||||
static String normalizePrefix(String prefix)
|
||||
{
|
||||
String snake = prefix.trim().replaceAll("([a-z0-9])([A-Z])", "$1_$2").replaceAll("[^A-Za-z0-9]+", "_").toLowerCase();
|
||||
return (snake.replaceAll("^_+", "").replaceAll("_+$", ""));
|
||||
}
|
||||
|
||||
|
||||
@@ -262,12 +319,8 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules for the qbit's tables: has-access on the admin base
|
||||
** name. Ordinary workbench users do not hold it, so the generic QQQ table
|
||||
** UI / API (/qqq/v1/table/...) cannot read other users' notebooks,
|
||||
** snapshots or the execution audit; the workbench's own endpoints read
|
||||
** and write these tables as the system user after their own owner /
|
||||
** visibility checks.
|
||||
** Permission rules for the admin-only surface: has-access on the admin
|
||||
** base name.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public QPermissionRules getAdminPermissionRules()
|
||||
@@ -277,6 +330,123 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules for the qbit's tables: QQQ's standard per-table
|
||||
** read / insert / edit / delete permissions ("<tableName>.read", ...).
|
||||
** Which rows a user sees and may change is decided by the tables' record
|
||||
** security locks (owner, shares, admin all-access), not by these.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public QPermissionRules getTablePermissionRules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Security key type whose session value is the user's id; the owner
|
||||
** locks match it.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getUserSecurityKeyTypeName()
|
||||
{
|
||||
return (applyPrefix(USER_SECURITY_KEY_TYPE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Security key type whose session values are the user's audience keys
|
||||
** ("user:<id>", "group:<name>", "all"); the share locks match it.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getAudienceSecurityKeyTypeName()
|
||||
{
|
||||
return (applyPrefix(AUDIENCE_SECURITY_KEY_TYPE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All-access key of the user key type: true for workbench admins.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getAllAccessSecurityKeyName()
|
||||
{
|
||||
return (applyPrefix(ALL_ACCESS_SECURITY_KEY_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every permission an ordinary workbench user needs: the access
|
||||
** permission, read / insert / edit / delete on notebooks and cells, read
|
||||
** on versions, shares and the execution audit, and read / insert (pin) /
|
||||
** edit (label) / delete on pinned snapshots. Record locks narrow every one of these to
|
||||
** the user's own (or shared-with-them, for reads) rows.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public List<String> getUserPermissionNames()
|
||||
{
|
||||
List<String> names = new ArrayList<>();
|
||||
names.add(getAccessPermissionName());
|
||||
addTablePermissions(names, SqlWorkbenchNotebook.TABLE_NAME, "read", "insert", "edit", "delete");
|
||||
addTablePermissions(names, SqlWorkbenchCell.TABLE_NAME, "read", "insert", "edit", "delete");
|
||||
addTablePermissions(names, SqlWorkbenchNotebookVersion.TABLE_NAME, "read");
|
||||
addTablePermissions(names, SqlWorkbenchResultSnapshot.TABLE_NAME, "read", "insert", "edit", "delete");
|
||||
addTablePermissions(names, SqlWorkbenchNotebookShare.TABLE_NAME, "read");
|
||||
addTablePermissions(names, SqlWorkbenchExecution.TABLE_NAME, "read");
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permissions to share one's notebooks (with users, groups, or the shared
|
||||
** library): the share permission plus insert / edit / delete on the share
|
||||
** table.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public List<String> getSharePermissionNames()
|
||||
{
|
||||
List<String> names = new ArrayList<>();
|
||||
names.add(sharePermissionName);
|
||||
addTablePermissions(names, SqlWorkbenchNotebookShare.TABLE_NAME, "insert", "edit", "delete");
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permissions for a workbench admin: everything a user has plus the admin
|
||||
** permission, which (through SqlWorkbenchSecurity.applyToSession) puts the
|
||||
** all-access key in the session - every user's notebooks and the whole
|
||||
** execution audit. Versions and the audit stay append-only for admins.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public List<String> getAdminPermissionNames()
|
||||
{
|
||||
List<String> names = new ArrayList<>(getUserPermissionNames());
|
||||
names.add(getAdminPermissionName());
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void addTablePermissions(List<String> names, String table, String... suffixes)
|
||||
{
|
||||
for(String suffix : suffixes)
|
||||
{
|
||||
names.add(applyPrefix(table) + "." + suffix);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QBitConfig: tables without a backend get this one.
|
||||
*******************************************************************************/
|
||||
@@ -1315,6 +1485,39 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for auditRetentionDays: execution audit rows older than this many
|
||||
** days are deleted (at most once an hour, when a run is audited); null (the
|
||||
** default) keeps them forever.
|
||||
*******************************************************************************/
|
||||
public Integer getAuditRetentionDays()
|
||||
{
|
||||
return (this.auditRetentionDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for auditRetentionDays
|
||||
*******************************************************************************/
|
||||
public void setAuditRetentionDays(Integer auditRetentionDays)
|
||||
{
|
||||
this.auditRetentionDays = auditRetentionDays;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for auditRetentionDays
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchQBitConfig withAuditRetentionDays(Integer auditRetentionDays)
|
||||
{
|
||||
this.auditRetentionDays = auditRetentionDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for registerRoutes
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -25,9 +25,11 @@ 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.help.bundles.HelpBundles;
|
||||
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.SqlWorkbenchHelp;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchTableMetaDataHelper;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
@@ -42,7 +44,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** 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.
|
||||
** /qqq/v1/sql-workbench UI API extension.
|
||||
**
|
||||
** Host usage:
|
||||
** new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
@@ -182,13 +184,21 @@ public class SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkben
|
||||
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// after the tables and app are in the instance: fill their empty help //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
SqlWorkbenchHelp.apply(qInstance, qBitConfig);
|
||||
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-sql-workbench.yaml");
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the runtime and register the REST routes.
|
||||
** Build the runtime and register the UI API extension.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void postProduceActions(MetaDataProducerMultiOutput output, QInstance qInstance) throws QException
|
||||
@@ -203,7 +213,7 @@ public class SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkben
|
||||
boolean routes = false;
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchRoutes.register(qInstance);
|
||||
com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchEndpoints.register(qInstance);
|
||||
routes = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel the caller's running execution.
|
||||
*******************************************************************************/
|
||||
public class CancelExecutionSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/executions/{uuid}/cancel", "Cancel the caller's running execution"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchCancelledV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("EmptyRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.cancel(caller, input.getPathParam("uuid")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a personal notebook.
|
||||
*******************************************************************************/
|
||||
public class CreateNotebookSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/notebooks", "Create a personal notebook"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchNotebookV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("NotebookRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.notebooks().create(input.getBody(), caller));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin the caller's cached result to an owned notebook.
|
||||
*******************************************************************************/
|
||||
public class CreateSnapshotSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/notebooks/{id}/snapshots", "Pin the caller's cached result to an owned notebook"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchSnapshotV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("SnapshotRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.createSnapshot(caller, pathInt(input, "id"), input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete an owned notebook.
|
||||
*******************************************************************************/
|
||||
public class DeleteNotebookSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.DELETE, "/notebooks/{id}", "Delete an owned notebook"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchDeletedV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
api.notebooks().delete(pathInt(input, "id"), caller);
|
||||
return (Map.of("deleted", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a snapshot from an owned notebook.
|
||||
*******************************************************************************/
|
||||
public class DeleteSnapshotSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.DELETE, "/notebooks/{id}/snapshots/{snapshotId}", "Delete a snapshot from an owned notebook"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchDeletedV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
api.notebooks().deleteSnapshot(pathInt(input, "id"), pathInt(input, "snapshotId"), caller);
|
||||
return (Map.of("deleted", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make a personal copy of a visible notebook.
|
||||
*******************************************************************************/
|
||||
public class DuplicateNotebookSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/notebooks/{id}/duplicate", "Make a personal copy of a visible notebook"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchNotebookV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("EmptyRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.notebooks().duplicate(pathInt(input, "id"), caller));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute read-only SQL or preview and confirm a gated write.
|
||||
*******************************************************************************/
|
||||
public class ExecuteSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/execute", "Execute read-only SQL or preview and confirm a gated write"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchExecutionV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("ExecuteRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.execute(caller, input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explain SQL; ANALYZE remains subject to write gates.
|
||||
*******************************************************************************/
|
||||
public class ExplainSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/explain", "Explain SQL; ANALYZE remains subject to write gates"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchExplainV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("ExplainRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.explain(caller, input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.AbstractMiddlewareExecutor;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareInput;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.AbstractMiddlewareOutputInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stream a caller-owned cached result without buffering it as a JSON response.
|
||||
*******************************************************************************/
|
||||
public class ExportResultSpec extends AbstractEndpointSpec<ExportResultSpec.ExportInput, ExportResultSpec.ExportOutput, ExportResultSpec.ExportExecutor>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the existing download endpoint.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (SqlWorkbenchJsonSpec.operation(HttpMethod.GET, "/executions/{uuid}/export", "Export the caller's cached result as CSV, JSON or XLSX"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeMethod(Method method)
|
||||
{
|
||||
method.setOperationId("sqlWorkbenchExportResult");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe UUID and format selection.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (SqlWorkbenchJsonSpec.parametersFor(defineBasicOperation().getPath(), "format"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All three media types are served by the same operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
List<BasicResponse> responses = new ArrayList<>(SqlWorkbenchJsonSpec.errors());
|
||||
for(ResultExporter.Format format : ResultExporter.Format.values())
|
||||
{
|
||||
responses.add(new BasicResponse(format.getContentType(), HttpStatus.OK,
|
||||
"Attachment named sql-result-{short UUID}.{format}; Cache-Control: no-store",
|
||||
format == ResultExporter.Format.JSON ? "SqlWorkbenchExportJsonV1" : "SqlWorkbenchExportFileV1", null));
|
||||
}
|
||||
return (responses);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check access before parsing format, just as the previous route did.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ExportInput buildInput(Context context) throws Exception
|
||||
{
|
||||
ExportInput input = new ExportInput();
|
||||
input.caller = new SqlWorkbenchApi().caller(context.ip());
|
||||
input.uuid = context.pathParam("uuid");
|
||||
input.format = ResultExporter.Format.of(StringUtils.hasContent(context.queryParam("format")) ? context.queryParam("format") : "csv");
|
||||
return (input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stream only after the executor authorizes access to the cached result.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void handleOutput(Context context, ExportOutput output) throws Exception
|
||||
{
|
||||
String shortId = output.uuid.length() > 8 ? output.uuid.substring(0, 8) : output.uuid;
|
||||
context.contentType(output.format.getContentType());
|
||||
context.header("Content-Disposition", "attachment; filename=\"sql-result-" + shortId.replaceAll("[^A-Za-z0-9-]", "") + "." + output.format.getExtension() + "\"");
|
||||
context.header("Cache-Control", "no-store");
|
||||
ResultExporter.write(output.entry.result(), output.format, context.outputStream());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Request state populated after session authentication.
|
||||
*******************************************************************************/
|
||||
public static class ExportInput extends AbstractMiddlewareInput
|
||||
{
|
||||
private Caller caller;
|
||||
private String uuid;
|
||||
private ResultExporter.Format format;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorized cached data and download metadata.
|
||||
*******************************************************************************/
|
||||
public static class ExportOutput implements AbstractMiddlewareOutputInterface
|
||||
{
|
||||
private ResultCache.Entry entry;
|
||||
private String uuid;
|
||||
private ResultExporter.Format format;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Service authorization stays identical to JSON result paging.
|
||||
*******************************************************************************/
|
||||
public static class ExportExecutor extends AbstractMiddlewareExecutor<ExportInput, ExportOutput>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve only the caller's cached execution.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void execute(ExportInput input, ExportOutput output) throws QException
|
||||
{
|
||||
output.entry = new SqlWorkbenchApi().exportable(input.caller, input.uuid);
|
||||
output.uuid = input.uuid;
|
||||
output.format = input.format;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get caller capabilities and configured limits.
|
||||
*******************************************************************************/
|
||||
public class GetCapabilitiesSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Declare shared components once. The extension registers all specs together.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.components());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/capabilities", "Get caller capabilities and configured limits"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchCapabilitiesV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.capabilities(caller));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List the caller's recent audited executions.
|
||||
*******************************************************************************/
|
||||
public class GetHistorySpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/history", "List the caller's recent audited executions"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("limit", "backendName"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchHistoryV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.history(caller, input.getQueryParamInteger("limit"), input.getQueryParam("backendName")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get a visible notebook and its cells.
|
||||
*******************************************************************************/
|
||||
public class GetNotebookSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/notebooks/{id}", "Get a visible notebook and its cells"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchNotebookV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.notebooks().get(pathInt(input, "id"), caller));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page or sort the caller's cached result.
|
||||
*******************************************************************************/
|
||||
public class GetRowsSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/executions/{uuid}/rows", "Page or sort the caller's cached result"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("offset", "limit", "sort", "dir"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchRowsV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.rows(caller, input.getPathParam("uuid"), input.getQueryParamInteger("offset"), input.getQueryParamInteger("limit"), input.getQueryParamInteger("sort"), input.getQueryParam("dir")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect a target backend schema.
|
||||
*******************************************************************************/
|
||||
public class GetSchemaSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/schema/{backendName}", "Inspect a target backend schema"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("refresh"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchSchemaV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.schema(caller, input.getPathParam("backendName"), "true".equalsIgnoreCase(input.getQueryParam("refresh"))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get a visible notebook version and saved content.
|
||||
*******************************************************************************/
|
||||
public class GetVersionSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/notebooks/{id}/versions/{n}", "Get a visible notebook version and saved content"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchVersionV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.notebooks().version(pathInt(input, "id"), pathInt(input, "n"), caller));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List visible personal or shared notebooks.
|
||||
*******************************************************************************/
|
||||
public class ListNotebooksSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/notebooks", "List visible personal or shared notebooks"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("scope"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchNotebooksV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (Map.of("notebooks", api.notebooks().list(input.getQueryParam("scope"), caller)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List snapshots of a visible notebook.
|
||||
*******************************************************************************/
|
||||
public class ListSnapshotsSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/notebooks/{id}/snapshots", "List snapshots of a visible notebook"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchSnapshotsV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (Map.of("snapshots", api.notebooks().snapshots(pathInt(input, "id"), caller)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** List versions of a visible notebook.
|
||||
*******************************************************************************/
|
||||
public class ListVersionsSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.GET, "/notebooks/{id}/versions", "List versions of a visible notebook"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchVersionsV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (Map.of("versions", api.notebooks().versions(pathInt(input, "id"), caller)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish or withdraw an owned notebook with share permission.
|
||||
*******************************************************************************/
|
||||
public class PublishNotebookSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/notebooks/{id}/publish", "Publish or withdraw an owned notebook with share permission"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchNotebookV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("PublishRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.notebooks().publish(pathInt(input, "id"), Boolean.TRUE.equals(input.getBody().get("shared")), caller));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore an owned notebook version as a new version.
|
||||
*******************************************************************************/
|
||||
public class RestoreVersionSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.POST, "/notebooks/{id}/versions/{n}/restore", "Restore an owned notebook version as a new version"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchNotebookV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("EmptyRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.notebooks().restore(pathInt(input, "id"), pathInt(input, "n"), caller));
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ 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.SystemUser;
|
||||
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;
|
||||
@@ -107,16 +107,12 @@ public class SqlWorkbenchApi
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
|
||||
String userId = session.getUser() != null && StringUtils.hasContent(session.getUser().getIdReference()) ? session.getUser().getIdReference() : session.getIdReference();
|
||||
String userName = session.getUser() != null && StringUtils.hasContent(session.getUser().getFullName()) ? session.getUser().getFullName() : userId;
|
||||
if(!StringUtils.hasContent(userId))
|
||||
if(!StringUtils.hasContent(SqlWorkbenchSecurity.userId(session)))
|
||||
{
|
||||
throw (new QAuthenticationException("The session has no user."));
|
||||
}
|
||||
|
||||
boolean canWrite = Boolean.TRUE.equals(config.getWriteModeEnabled()) && session.hasPermission(config.getWritePermissionName());
|
||||
boolean canShare = session.hasPermission(config.getSharePermissionName());
|
||||
return (new Caller(userId, userName, canWrite, canShare, clientIp));
|
||||
SqlWorkbenchSecurity.ensureSessionKeys(config, session);
|
||||
return (Caller.fromSession(config, session, clientIp));
|
||||
}
|
||||
|
||||
|
||||
@@ -264,10 +260,10 @@ public class SqlWorkbenchApi
|
||||
|
||||
List<Map<String, Object>> executions = new ArrayList<>();
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// the audit table needs the admin permission; the filter above is //
|
||||
// the boundary (own rows only), so read it as the system user //
|
||||
// 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 : SystemUser.call(() -> QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), filter)))
|
||||
for(QRecord record : QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), filter))
|
||||
{
|
||||
SqlWorkbenchExecution execution = new SqlWorkbenchExecution(record);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
@@ -307,10 +303,10 @@ public class SqlWorkbenchApi
|
||||
{
|
||||
String executionUuid = normalizeUuid(requireString(body, "executionUuid"));
|
||||
cached(caller, executionUuid);
|
||||
List<QRecord> audit = SystemUser.call(() -> QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME),
|
||||
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)));
|
||||
.withLimit(1));
|
||||
String sqlText = audit.isEmpty() ? null : audit.get(0).getValueString("sqlText");
|
||||
|
||||
Map<String, Object> normalized = new LinkedHashMap<>(body);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers all SQL Workbench operations as UI API specs.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchEndpoints
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Register the extension idempotently.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).withExtension(extension());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The complete UI API extension at its established prefix.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("sql-workbench").withPathPrefix("/sql-workbench")
|
||||
.withSemanticVersion("1.0.0").withDescription("Audited SQL execution and notebooks")
|
||||
.withSpec(GetCapabilitiesSpec.class)
|
||||
.withSpec(GetSchemaSpec.class)
|
||||
.withSpec(ExecuteSpec.class)
|
||||
.withSpec(ExplainSpec.class)
|
||||
.withSpec(GetRowsSpec.class)
|
||||
.withSpec(CancelExecutionSpec.class)
|
||||
.withSpec(GetHistorySpec.class)
|
||||
.withSpec(ListNotebooksSpec.class)
|
||||
.withSpec(CreateNotebookSpec.class)
|
||||
.withSpec(GetNotebookSpec.class)
|
||||
.withSpec(UpdateNotebookSpec.class)
|
||||
.withSpec(DeleteNotebookSpec.class)
|
||||
.withSpec(PublishNotebookSpec.class)
|
||||
.withSpec(DuplicateNotebookSpec.class)
|
||||
.withSpec(ListVersionsSpec.class)
|
||||
.withSpec(GetVersionSpec.class)
|
||||
.withSpec(RestoreVersionSpec.class)
|
||||
.withSpec(ListSnapshotsSpec.class)
|
||||
.withSpec(CreateSnapshotSpec.class)
|
||||
.withSpec(DeleteSnapshotSpec.class)
|
||||
.withSpec(ExportResultSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.DuplicateExecutionException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.RequestBody;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared transport; the existing services retain authorization, limits and audit.
|
||||
*******************************************************************************/
|
||||
public abstract class SqlWorkbenchJsonSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check access before parsing; preserve exact decimal parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public JsonInput buildInput(Context context) throws Exception
|
||||
{
|
||||
Caller caller = new SqlWorkbenchApi().caller(context.ip());
|
||||
WorkbenchInput input = new WorkbenchInput(this, caller);
|
||||
input.getPathParams().putAll(context.pathParamMap());
|
||||
context.queryParamMap().forEach((name, values) -> input.getQueryParams().put(name, values == null || values.isEmpty() ? null : values.get(0)));
|
||||
if(defineRequestBodySchema() != null)
|
||||
{
|
||||
input.getBody().putAll(readExactBody(context));
|
||||
}
|
||||
return (input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the established JSON object shape without losing decimal precision.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> readExactBody(Context context) throws QBadRequestException
|
||||
{
|
||||
if(!StringUtils.hasContent(context.body()))
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
if(context.contentType() == null || !context.contentType().toLowerCase().startsWith("application/json"))
|
||||
{
|
||||
throw (new QBadRequestException("Request bodies must be JSON (Content-Type: application/json)."));
|
||||
}
|
||||
try
|
||||
{
|
||||
Map<String, Object> body = JsonUtils.toObject(context.body(), new TypeReference<Map<String, Object>>() {}, mapper -> mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS));
|
||||
return (body == null ? Map.of() : body);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QBadRequestException("The request body is not a JSON object."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve duplicate execution UUID conflicts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void handleException(Context context, Exception e)
|
||||
{
|
||||
QJavalinUtils.handleException(e instanceof DuplicateExecutionException ? org.eclipse.jetty.http.HttpStatus.Code.CONFLICT : null, context, e);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bodies remain optional; empty bodies retain their existing service semantics.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RequestBody defineRequestBody()
|
||||
{
|
||||
RequestBody body = super.defineRequestBody();
|
||||
return (body == null ? null : body.withRequired(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable operation identifiers for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeMethod(Method method)
|
||||
{
|
||||
method.setOperationId("sqlWorkbench" + getClass().getSimpleName().replace("Spec", ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the established extension prefix.
|
||||
*******************************************************************************/
|
||||
static BasicOperation operation(HttpMethod method, String path, String summary)
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(method).withPath("/sql-workbench" + path)
|
||||
.withShortSummary(summary).withLongDescription(summary + ". Requires SQL Workbench access; services enforce caller permissions, ownership, execution limits and audit."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Document existing request error statuses.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (errors());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share error schemas with the export stream.
|
||||
*******************************************************************************/
|
||||
static List<BasicResponse> errors()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input or execution limit exceeded", "SqlWorkbenchErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "SqlWorkbenchErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "SqlWorkbenchErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible resource", "SqlWorkbenchErrorV1"),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "Execution UUID already used", "SqlWorkbenchErrorV1")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Path and query parameter declarations.
|
||||
*******************************************************************************/
|
||||
protected List<Parameter> parameters(String... queries)
|
||||
{
|
||||
return (parametersFor(defineBasicOperation().getPath(), queries));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share parameter declarations with the export stream.
|
||||
*******************************************************************************/
|
||||
static List<Parameter> parametersFor(String path, String... queries)
|
||||
{
|
||||
List<Parameter> parameters = new ArrayList<>();
|
||||
Matcher matcher = Pattern.compile("\\{([^}]+)}").matcher(path);
|
||||
while(matcher.find())
|
||||
{
|
||||
parameters.add(parameter(matcher.group(1), In.PATH));
|
||||
}
|
||||
for(String query : queries)
|
||||
{
|
||||
parameters.add(parameter(query, In.QUERY));
|
||||
}
|
||||
return (parameters);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Document parameter types and default behavior.
|
||||
*******************************************************************************/
|
||||
private static Parameter parameter(String name, In location)
|
||||
{
|
||||
Type type = Type.STRING;
|
||||
if(List.of("id", "n", "snapshotId", "offset", "limit", "sort").contains(name))
|
||||
{
|
||||
type = Type.INTEGER;
|
||||
}
|
||||
else if("refresh".equals(name))
|
||||
{
|
||||
type = Type.BOOLEAN;
|
||||
}
|
||||
return (new Parameter().withName(name).withIn(location).withRequired(location == In.PATH)
|
||||
.withDescription(parameterDescription(name)).withSchema(new Schema().withType(type)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parameter help for generated clients.
|
||||
*******************************************************************************/
|
||||
private static String parameterDescription(String name)
|
||||
{
|
||||
switch(name)
|
||||
{
|
||||
case "id":
|
||||
return ("Notebook identifier.");
|
||||
case "n":
|
||||
return ("Notebook version number.");
|
||||
case "snapshotId":
|
||||
return ("Snapshot identifier.");
|
||||
case "uuid":
|
||||
return ("Execution UUID; case insensitive.");
|
||||
case "backendName":
|
||||
return ("Configured target backend name.");
|
||||
case "offset":
|
||||
return ("Rows to skip; default zero; negative values become zero.");
|
||||
case "limit":
|
||||
return ("Page size; nonpositive or absent uses the default. Rows cap at maxPageSize; history caps at 200 (default 50).");
|
||||
case "sort":
|
||||
return ("Optional zero-based result column index.");
|
||||
case "dir":
|
||||
return ("Sort direction: desc (case insensitive) or asc (default).");
|
||||
case "refresh":
|
||||
return ("True to bypass the schema cache; default false.");
|
||||
case "scope":
|
||||
return ("mine (default) or shared notebook library.");
|
||||
case "format":
|
||||
return ("csv (default), json or xlsx; case insensitive.");
|
||||
default:
|
||||
return (name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve integer path validation.
|
||||
*******************************************************************************/
|
||||
protected static Integer pathInt(JsonInput input, String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(input.getPathParam(name)));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be an integer."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller captured before body parsing, including the audit client IP.
|
||||
*******************************************************************************/
|
||||
protected static Caller caller(JsonInput input)
|
||||
{
|
||||
return (((WorkbenchInput) input).caller);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-request state; spec instances themselves are shared across requests.
|
||||
*******************************************************************************/
|
||||
private static class WorkbenchInput extends JsonInput
|
||||
{
|
||||
private final SqlWorkbenchJsonSpec spec;
|
||||
private final Caller caller;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the spec and authorized caller.
|
||||
*******************************************************************************/
|
||||
private WorkbenchInput(SqlWorkbenchJsonSpec spec, Caller caller)
|
||||
{
|
||||
this.spec = spec;
|
||||
this.caller = caller;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supply the spec to the framework's JSON executor.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AbstractJsonEndpointSpec getSpec()
|
||||
{
|
||||
return (spec);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the established integer-query error message.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer getQueryParamInteger(String name) throws QBadRequestException
|
||||
{
|
||||
String value = getQueryParam(name);
|
||||
if(!StringUtils.hasContent(value))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(value.trim()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be an integer."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned wire schemas shared by SQL Workbench specs.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Explicit schemas for the existing map payloads; no serialization changes.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> components()
|
||||
{
|
||||
Map<String, Schema> schemas = new LinkedHashMap<>();
|
||||
schemas.put("SqlWorkbenchErrorV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("error", new Schema().withType(Type.STRING)));
|
||||
schemas.put("SqlWorkbenchEmptyRequestV1", new Schema().withType(Type.OBJECT));
|
||||
schemas.put("SqlWorkbenchDeletedV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("deleted", new Schema().withType(Type.BOOLEAN)));
|
||||
schemas.put("SqlWorkbenchCancelledV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("cancelled", new Schema().withType(Type.BOOLEAN)));
|
||||
schemas.put("SqlWorkbenchLimitsV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("maxRows", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("pageSize", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("maxPageSize", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("maxResultBytes", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("statementTimeoutSeconds", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("writeStatementTimeoutSeconds", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("maxAffectedRowsWithoutAcknowledgement", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("maxAffectedRows", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("maxSqlLength", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("maxStatementsPerRun", new Schema().withType(Type.INTEGER).withFormat("int64")));
|
||||
schemas.put("SqlWorkbenchBackendV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("label", new Schema().withType(Type.STRING))
|
||||
.withProperty("vendor", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("dialect", ref("Dialect"))
|
||||
.withProperty("readOnlyCredentials", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("readOnlyEnforced", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("transactionalDdl", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("writeAllowed", new Schema().withType(Type.BOOLEAN)));
|
||||
schemas.put("SqlWorkbenchCapabilitiesV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("version", new Schema().withType(Type.STRING))
|
||||
.withProperty("canWrite", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("canShare", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("writeModeEnabled", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("limits", ref("Limits"))
|
||||
.withProperty("backends", array(ref("Backend"))));
|
||||
schemas.put("SqlWorkbenchColumnV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("label", new Schema().withType(Type.STRING))
|
||||
.withProperty("typeName", new Schema().withType(Type.STRING))
|
||||
.withProperty("jdbcType", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("kind", ref("ColumnKind")));
|
||||
schemas.put("SqlWorkbenchBinaryValueV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("$binary", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("length", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("hexPreview", new Schema().withType(Type.STRING)));
|
||||
schemas.put("SqlWorkbenchStatementV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("index", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("kind", ref("StatementKind"))
|
||||
.withProperty("leadingKeyword", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("text", new Schema().withType(Type.STRING))
|
||||
.withProperty("affectedRows", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true)));
|
||||
schemas.put("SqlWorkbenchExecuteRequestV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING))
|
||||
.withProperty("sql", new Schema().withType(Type.STRING))
|
||||
.withProperty("params", ref("Parameters"))
|
||||
.withProperty("mode", new Schema().withType(Type.STRING))
|
||||
.withProperty("notebookId", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("cellKey", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("pageSize", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("confirmToken", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("acknowledgeRowGuard", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("executionUuid", new Schema().withType(Type.STRING).withNullable(true)));
|
||||
schemas.put("SqlWorkbenchExplainRequestV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING))
|
||||
.withProperty("sql", new Schema().withType(Type.STRING))
|
||||
.withProperty("params", ref("Parameters"))
|
||||
.withProperty("analyze", new Schema().withType(Type.BOOLEAN)));
|
||||
schemas.put("SqlWorkbenchExecutionV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("executionUuid", new Schema().withType(Type.STRING))
|
||||
.withProperty("status", ref("Status"))
|
||||
.withProperty("mode", new Schema().withType(Type.STRING))
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING))
|
||||
.withProperty("durationMs", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("statements", array(ref("Statement")))
|
||||
.withProperty("columns", array(ref("Column")))
|
||||
.withProperty("rows", ref("RowsData"))
|
||||
.withProperty("rowCount", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("hasMore", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("truncated", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("truncatedReason", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("rowsAffected", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("error", new Schema().withType(Type.STRING))
|
||||
.withProperty("sqlState", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("dryRunSupported", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("dryRunNote", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("totalAffectedRows", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("rowGuardExceeded", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("rowGuardLimit", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("confirmToken", new Schema().withType(Type.STRING))
|
||||
.withProperty("expiresAt", new Schema().withType(Type.STRING))
|
||||
.withProperty("previewAffectedRows", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("actualAffectedRows", new Schema().withType(Type.INTEGER).withFormat("int64")));
|
||||
schemas.put("SqlWorkbenchRowsV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("executionUuid", new Schema().withType(Type.STRING))
|
||||
.withProperty("columns", array(ref("Column")))
|
||||
.withProperty("rows", ref("RowsData"))
|
||||
.withProperty("offset", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("rowCount", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("truncated", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("truncatedReason", new Schema().withType(Type.STRING).withNullable(true)));
|
||||
schemas.put("SqlWorkbenchPlanNodeV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("label", new Schema().withType(Type.STRING))
|
||||
.withProperty("detail", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("startupCost", new Schema().withType(Type.NUMBER).withNullable(true))
|
||||
.withProperty("totalCost", new Schema().withType(Type.NUMBER).withNullable(true))
|
||||
.withProperty("planRows", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("actualTimeMs", new Schema().withType(Type.NUMBER).withNullable(true))
|
||||
.withProperty("actualRows", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("loops", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("extra", ref("FreeObject"))
|
||||
.withProperty("children", array(ref("PlanNode"))));
|
||||
schemas.put("SqlWorkbenchExplainV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("executionUuid", new Schema().withType(Type.STRING))
|
||||
.withProperty("status", ref("Status"))
|
||||
.withProperty("mode", new Schema().withType(Type.STRING))
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING))
|
||||
.withProperty("dialect", ref("Dialect"))
|
||||
.withProperty("analyze", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("plan", ref("PlanNode"))
|
||||
.withProperty("raw", new Schema().withType(Type.STRING))
|
||||
.withProperty("error", new Schema().withType(Type.STRING))
|
||||
.withProperty("sqlState", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("durationMs", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true)));
|
||||
schemas.put("SqlWorkbenchSchemaColumnV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("typeName", new Schema().withType(Type.STRING))
|
||||
.withProperty("nullable", new Schema().withType(Type.BOOLEAN)));
|
||||
schemas.put("SqlWorkbenchSchemaTableV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("type", new Schema().withType(Type.STRING))
|
||||
.withProperty("columns", array(ref("SchemaColumn"))));
|
||||
schemas.put("SqlWorkbenchDatabaseSchemaV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("tables", array(ref("SchemaTable"))));
|
||||
schemas.put("SqlWorkbenchQqqFieldV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("label", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("sqlName", new Schema().withType(Type.STRING))
|
||||
.withProperty("type", new Schema().withType(Type.STRING).withNullable(true)));
|
||||
schemas.put("SqlWorkbenchQqqTableV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("label", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("sqlName", new Schema().withType(Type.STRING))
|
||||
.withProperty("fields", array(ref("QqqField"))));
|
||||
schemas.put("SqlWorkbenchSchemaV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING))
|
||||
.withProperty("dialect", ref("Dialect"))
|
||||
.withProperty("schemas", array(ref("DatabaseSchema")))
|
||||
.withProperty("truncated", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("keywords", array(new Schema().withType(Type.STRING)))
|
||||
.withProperty("functions", array(new Schema().withType(Type.STRING)))
|
||||
.withProperty("qqqTables", array(ref("QqqTable")))
|
||||
.withProperty("cachedAt", new Schema().withType(Type.STRING)));
|
||||
schemas.put("SqlWorkbenchHistoryEntryV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("executionUuid", new Schema().withType(Type.STRING))
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING))
|
||||
.withProperty("mode", new Schema().withType(Type.STRING))
|
||||
.withProperty("status", new Schema().withType(Type.STRING))
|
||||
.withProperty("sqlText", new Schema().withType(Type.STRING))
|
||||
.withProperty("notebookId", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("cellKey", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("startTime", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("durationMs", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("rowsReturned", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("rowsAffected", new Schema().withType(Type.INTEGER).withFormat("int64").withNullable(true))
|
||||
.withProperty("errorMessage", new Schema().withType(Type.STRING).withNullable(true)));
|
||||
schemas.put("SqlWorkbenchHistoryV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("executions", array(ref("HistoryEntry"))));
|
||||
schemas.put("SqlWorkbenchNotebookParameterV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("name", new Schema().withType(Type.STRING))
|
||||
.withProperty("type", new Schema().withType(Type.STRING))
|
||||
.withProperty("defaultValue", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("label", new Schema().withType(Type.STRING).withNullable(true)));
|
||||
schemas.put("SqlWorkbenchCellV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("cellKey", new Schema().withType(Type.STRING))
|
||||
.withProperty("cellType", new Schema().withType(Type.STRING))
|
||||
.withProperty("title", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("content", new Schema().withType(Type.STRING))
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("sortOrder", new Schema().withType(Type.INTEGER).withFormat("int64")));
|
||||
schemas.put("SqlWorkbenchNotebookSummaryV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("id", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("title", new Schema().withType(Type.STRING))
|
||||
.withProperty("description", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("visibility", new Schema().withType(Type.STRING))
|
||||
.withProperty("ownerUserId", new Schema().withType(Type.STRING))
|
||||
.withProperty("ownerName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("defaultBackendName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("currentVersion", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("canEdit", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("createDate", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("modifyDate", new Schema().withType(Type.STRING).withNullable(true)));
|
||||
schemas.put("SqlWorkbenchNotebookV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("id", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("title", new Schema().withType(Type.STRING))
|
||||
.withProperty("description", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("visibility", new Schema().withType(Type.STRING))
|
||||
.withProperty("ownerUserId", new Schema().withType(Type.STRING))
|
||||
.withProperty("ownerName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("defaultBackendName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("currentVersion", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("canEdit", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("createDate", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("modifyDate", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("parameters", array(ref("NotebookParameter")))
|
||||
.withProperty("cells", array(ref("Cell"))));
|
||||
schemas.put("SqlWorkbenchNotebookContentV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("title", new Schema().withType(Type.STRING))
|
||||
.withProperty("description", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("defaultBackendName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("parameters", array(ref("NotebookParameter")))
|
||||
.withProperty("cells", array(ref("Cell"))));
|
||||
schemas.put("SqlWorkbenchNotebookRequestV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("title", new Schema().withType(Type.STRING))
|
||||
.withProperty("description", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("defaultBackendName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("parameters", array(ref("NotebookParameter")))
|
||||
.withProperty("cells", array(ref("Cell")))
|
||||
.withProperty("comment", new Schema().withType(Type.STRING)));
|
||||
schemas.put("SqlWorkbenchNotebooksV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("notebooks", array(ref("NotebookSummary"))));
|
||||
schemas.put("SqlWorkbenchPublishRequestV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("shared", new Schema().withType(Type.BOOLEAN)));
|
||||
schemas.put("SqlWorkbenchVersionSummaryV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("versionNumber", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("savedByName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("comment", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("createDate", new Schema().withType(Type.STRING)));
|
||||
schemas.put("SqlWorkbenchVersionV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("versionNumber", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("savedByName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("comment", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("createDate", new Schema().withType(Type.STRING))
|
||||
.withProperty("notebook", ref("NotebookContent")));
|
||||
schemas.put("SqlWorkbenchVersionsV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("versions", array(ref("VersionSummary"))));
|
||||
schemas.put("SqlWorkbenchSnapshotRequestV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("executionUuid", new Schema().withType(Type.STRING))
|
||||
.withProperty("cellKey", new Schema().withType(Type.STRING))
|
||||
.withProperty("label", new Schema().withType(Type.STRING).withNullable(true)));
|
||||
schemas.put("SqlWorkbenchSnapshotResultV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("columns", array(ref("Column")))
|
||||
.withProperty("rows", ref("RowsData")));
|
||||
schemas.put("SqlWorkbenchSnapshotV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("id", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("cellKey", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("executionUuid", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("label", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("backendName", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("rowCount", new Schema().withType(Type.INTEGER).withFormat("int64"))
|
||||
.withProperty("truncated", new Schema().withType(Type.BOOLEAN))
|
||||
.withProperty("createDate", new Schema().withType(Type.STRING).withNullable(true))
|
||||
.withProperty("result", ref("SnapshotResult")));
|
||||
schemas.put("SqlWorkbenchSnapshotsV1", new Schema().withType(Type.OBJECT)
|
||||
.withProperty("snapshots", array(ref("Snapshot"))));
|
||||
schemas.put("SqlWorkbenchDialectV1", new Schema().withType(Type.STRING).withEnumValues(List.of("postgresql", "mysql", "sqlite", "h2", "sql")));
|
||||
schemas.put("SqlWorkbenchColumnKindV1", new Schema().withType(Type.STRING).withEnumValues(List.of("number", "string", "boolean", "date", "datetime", "time", "json", "binary", "array", "other")));
|
||||
schemas.put("SqlWorkbenchStatementKindV1", new Schema().withType(Type.STRING).withEnumValues(List.of("READ", "EXPLAIN_ANALYZE", "WRITE", "DDL", "FORBIDDEN")));
|
||||
schemas.put("SqlWorkbenchStatusV1", new Schema().withType(Type.STRING).withEnumValues(List.of("SUCCEEDED", "FAILED", "TIMED_OUT", "CANCELLED", "REJECTED", "PREVIEW", "DRIFTED")));
|
||||
schemas.put("SqlWorkbenchParametersV1", new Schema().withType(Type.OBJECT).withAdditionalProperties(new Schema())
|
||||
.withDescription("Named SQL parameter values. JSON decimals are parsed exactly as BigDecimal."));
|
||||
schemas.put("SqlWorkbenchFreeObjectV1", new Schema().withType(Type.OBJECT).withAdditionalProperties(true));
|
||||
schemas.put("SqlWorkbenchValueV1", new Schema().withAnyOf(List.of(
|
||||
new Schema().withType(Type.STRING).withNullable(true), new Schema().withType(Type.NUMBER), new Schema().withType(Type.BOOLEAN),
|
||||
ref("BinaryValue"), array(ref("Value")), ref("FreeObject")))
|
||||
.withDescription("JSON-safe JDBC value; null, scalar, binary preview, nested array or vendor JSON object."));
|
||||
schemas.put("SqlWorkbenchRowsDataV1", array(array(ref("Value"))));
|
||||
schemas.put("SqlWorkbenchExportFileV1", new Schema().withType(Type.STRING).withFormat("binary"));
|
||||
schemas.put("SqlWorkbenchExportJsonV1", array(new Schema().withType(Type.OBJECT).withAdditionalProperties(ref("Value")))
|
||||
.withDescription("Rows keyed by unique column labels, with duplicate labels suffixed."));
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reference a component in this extension's namespace.
|
||||
*******************************************************************************/
|
||||
public static Schema ref(String name)
|
||||
{
|
||||
return (new Schema().withRefToSchema("SqlWorkbench" + name + "V1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An array with explicitly described items.
|
||||
*******************************************************************************/
|
||||
private static Schema array(Schema items)
|
||||
{
|
||||
return (new Schema().withType(Type.ARRAY).withItems(items));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update an owned notebook and record a version.
|
||||
*******************************************************************************/
|
||||
public class UpdateNotebookSpec extends SqlWorkbenchJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (operation(HttpMethod.PUT, "/notebooks/{id}", "Update an owned notebook and record a version"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe path and query parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("SqlWorkbenchNotebookV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the JSON body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (SqlWorkbenchSchemas.ref("NotebookRequest"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Caller caller = caller(input);
|
||||
return (api.notebooks().update(pathInt(input, "id"), input.getBody(), caller));
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 (auditRetentionDays, notebookVersionRetention)
|
||||
** 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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) throws QException
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
validateBackend(record, "backendName");
|
||||
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() });
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* 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) throws QException
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
validateBackend(record, "defaultBackendName");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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.engine.WorkbenchBackends;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional backend selections must name an allowed workbench target.
|
||||
*******************************************************************************/
|
||||
protected static void validateBackend(QRecord record, String fieldName) throws QException
|
||||
{
|
||||
String name = record.getValueString(fieldName);
|
||||
if(name != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
new WorkbenchBackends(QContext.getQInstance(), SqlWorkbenchRuntime.get()).getTarget(name);
|
||||
}
|
||||
catch(QNotFoundException e)
|
||||
{
|
||||
badInput(record, "Select an available SQL Workbench backend.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* 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)
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
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).
|
||||
@@ -29,4 +33,16 @@ package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
*******************************************************************************/
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,27 @@ package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
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.UpdateAction;
|
||||
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.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
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.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
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;
|
||||
@@ -50,7 +63,20 @@ public class ExecutionAuditor
|
||||
|
||||
public static final int MAX_ERROR_LENGTH = 4000;
|
||||
|
||||
public static final long PRUNE_INTERVAL_MILLIS = 60L * 60 * 1000;
|
||||
public static final long FIRST_PRUNE_DELAY_MILLIS = 5L * 60 * 1000;
|
||||
public static final int BATCH_SIZE = 1000;
|
||||
public static final int MAX_BATCHES = 20;
|
||||
|
||||
private static final ExecutorService PRUNER = Executors.newSingleThreadExecutor(runnable ->
|
||||
{
|
||||
Thread thread = new Thread(runnable, "sql-workbench-audit-prune");
|
||||
thread.setDaemon(true);
|
||||
return (thread);
|
||||
});
|
||||
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
private final AtomicLong lastPruneMillis = new AtomicLong(System.currentTimeMillis() - PRUNE_INTERVAL_MILLIS + FIRST_PRUNE_DELAY_MILLIS);
|
||||
|
||||
|
||||
|
||||
@@ -92,11 +118,114 @@ public class ExecutionAuditor
|
||||
|
||||
throwIfNotInserted(inserted[0], "Could not write the SQL Workbench audit record; the SQL was not run.");
|
||||
execution.setId(inserted[0].getValueInteger("id"));
|
||||
pruneIfDue();
|
||||
return (execution.getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For tests: make the next pruneIfDue start a pass now.
|
||||
*******************************************************************************/
|
||||
void allowPruneNow()
|
||||
{
|
||||
lastPruneMillis.set(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply auditRetentionDays in the background, at most once an hour per
|
||||
** process, and never the moment a process starts (the first pass is
|
||||
** FIRST_PRUNE_DELAY_MILLIS after construction, so restarts and rolling
|
||||
** deploys do not each trigger a pass). Never delays or fails the run being
|
||||
** audited: a failed pass is logged and tried again an hour later.
|
||||
**
|
||||
** Across replicas: the qbit has no table for shared state, so each replica
|
||||
** keeps its own hourly limit. That is safe because a pass is bounded
|
||||
** (MAX_BATCHES batches of BATCH_SIZE rows) and idempotent: replicas that
|
||||
** overlap delete rows another replica already deleted, which costs one
|
||||
** cheap query. N replicas therefore cost at most N bounded passes an hour.
|
||||
**
|
||||
** @return the pending pass, or null when none was started
|
||||
*******************************************************************************/
|
||||
Future<?> pruneIfDue()
|
||||
{
|
||||
if(config.getAuditRetentionDays() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long last = lastPruneMillis.get();
|
||||
if(now - last < PRUNE_INTERVAL_MILLIS || !lastPruneMillis.compareAndSet(last, now))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
Instant cutoff = Instant.ofEpochMilli(now).minus(Duration.ofDays(config.getAuditRetentionDays()));
|
||||
return (PRUNER.submit(() ->
|
||||
{
|
||||
QContext.init(qInstance, new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
pruneOlderThan(cutoff);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not prune old SQL Workbench audit rows", e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete audit rows that started before a cutoff (system user; the table is
|
||||
** otherwise append-only), in batches of BATCH_SIZE ids (so a large backlog
|
||||
** never loads whole rows, with their SQL text, or builds an unbounded IN
|
||||
** list), at most MAX_BATCHES batches per call; the rest goes on the next
|
||||
** pass. Rows still RUNNING are kept.
|
||||
**
|
||||
** @return the number of rows deleted
|
||||
*******************************************************************************/
|
||||
public int pruneOlderThan(Instant cutoff) throws QException
|
||||
{
|
||||
int[] deleted = new int[1];
|
||||
asSystem(() ->
|
||||
{
|
||||
for(int batch = 0; batch < MAX_BATCHES; batch++)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("startTime", QCriteriaOperator.LESS_THAN, cutoff))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.NOT_EQUALS, ExecutionStatus.RUNNING.name()))
|
||||
.withOrderBy(new QFilterOrderBy("id"))
|
||||
.withLimit(BATCH_SIZE);
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
for(QRecord record : new QueryAction().execute(new QueryInput(tableName()).withFilter(filter)).getRecords())
|
||||
{
|
||||
ids.add(record.getValueInteger("id"));
|
||||
}
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
deleted[0] += new DeleteAction().execute(new DeleteInput(tableName()).withPrimaryKeys(ids)).getDeletedRecordCount();
|
||||
if(ids.size() < BATCH_SIZE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return (deleted[0]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the outcome.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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)));
|
||||
}
|
||||
|
||||
}
|
||||
+27
-7
@@ -17,6 +17,7 @@
|
||||
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;
|
||||
@@ -32,16 +33,30 @@ import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sqlWorkbench app: the workbench widget (rendered by the Next UI as the
|
||||
** notebook UI), plus the notebook and execution-audit tables. Gated by the
|
||||
** access permission, so the app - and so the feature - is only advertised
|
||||
** to users who have it. No processes (they would only add left-nav noise).
|
||||
** 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);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -57,12 +72,17 @@ public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProduc
|
||||
.withLabel("SQL Workbench")
|
||||
.withIcon(new QIcon("terminal"))
|
||||
.withPermissionRules(config.getAccessPermissionRules())
|
||||
.withWidgets(List.of(config.applyPrefix(SqlWorkbenchWidgetMetaDataProducer.NAME)));
|
||||
.withWidgets(new ArrayList<>(SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames().stream().map(config::applyPrefix).toList()));
|
||||
|
||||
MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput();
|
||||
if(produced != null)
|
||||
{
|
||||
for(String tableName : new String[] { SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME })
|
||||
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)
|
||||
@@ -77,7 +97,7 @@ public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProduc
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** last, so the tables and widget exist in the output
|
||||
** last, so the tables, widgets and Runner app exist in the output
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchCell table.
|
||||
** Produces the sql_workbench_cell table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCellTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
+15
-17
@@ -14,36 +14,34 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
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.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.developer.SqlWorkbenchDeveloperPanelProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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).
|
||||
** 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 SqlWorkbenchRoutes
|
||||
public class SqlWorkbenchDeveloperPanelsMetaDataProducer extends QBitComponentMetaDataProducer<DeveloperPanelsMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the route provider reference, once.
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
@Override
|
||||
public DeveloperPanelsMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
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));
|
||||
}
|
||||
return (DeveloperPanelsMetaData.ofOrWithNew(qInstance).withProvider(new QCodeReference(SqlWorkbenchDeveloperPanelProvider.class)));
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchExecution (audit) table.
|
||||
** Produces the sql_workbench_execution (audit) table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchExecutionTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
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.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-sql-workbench.yaml (the QQQ help-bundle format) to the SQL
|
||||
** Workbench app and its sql_workbench_* tables: what each table is for and
|
||||
** how its rows get created, shown on the table screen and when it is empty.
|
||||
** Retains inline app/table help for hosts without helpContent, including
|
||||
** prefixed metadata. Core bundle registration supplies the full help catalog.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-sql-workbench.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = SqlWorkbenchHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-sql-workbench".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every sql_workbench_* table in the instance
|
||||
** (names carry the config's prefix, if any). A host's own help on a table
|
||||
** is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
|
||||
{
|
||||
String key = entry.getKey();
|
||||
if(!key.endsWith(";slot:help"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String name = key.substring(key.indexOf(':') + 1, key.indexOf(';'));
|
||||
if(key.startsWith("table:"))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(config.applyPrefix(name));
|
||||
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry.getValue()));
|
||||
}
|
||||
}
|
||||
else if(key.startsWith("app:"))
|
||||
{
|
||||
QAppMetaData app = qInstance.getApp(config.applyPrefix(name));
|
||||
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(entry.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchNotebook table.
|
||||
** Produces the sql_workbench_notebook table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchNotebookVersion table.
|
||||
** Produces the sql_workbench_notebook_version table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookVersionTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -25,7 +25,7 @@ import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchResultSnapshot table.
|
||||
** Produces the sql_workbench_result_snapshot table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchResultSnapshotTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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.qbits.sqlworkbench.model.CellType;
|
||||
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.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.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));
|
||||
output.add(QPossibleValueSource.newForEnum(config.applyPrefix(CellType.NAME), CellType.values()));
|
||||
output.add(new QPossibleValueSource().withName(config.applyPrefix(WorkbenchBackendPossibleValueProvider.NAME))
|
||||
.withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING)
|
||||
.withCustomCodeReference(new QCodeReference(WorkbenchBackendPossibleValueProvider.class)));
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+347
-49
@@ -17,104 +17,230 @@
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
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.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
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.CellType;
|
||||
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 QTableMetaData of the five SQL Workbench tables. Shared by the
|
||||
** component producers and by tests.
|
||||
** Builds the SQL Workbench tables, joins and record-view widgets. Shared by
|
||||
** the component producers and by tests.
|
||||
**
|
||||
** All tables are protected by the ADMIN permission
|
||||
** (sqlWorkbenchAdmin.hasAccess), not the access permission every workbench
|
||||
** user holds: the generic QQQ table UI / API would otherwise let any user
|
||||
** read other users' personal notebooks, pinned result data and the full
|
||||
** execution audit. The workbench endpoints do their own owner / visibility
|
||||
** checks and then read and write these tables as the system user.
|
||||
** The tables are ordinary QQQ tables (query, view, edit, export, audits,
|
||||
** developer mode), secured by QQQ mechanisms rather than by hiding them:
|
||||
**
|
||||
** Cells, versions, snapshots and the audit are read-only even for admins
|
||||
** (insert / edit / delete capabilities disabled); only the qbit writes
|
||||
** them. Notebooks stay editable for admins (e.g. to clean up).
|
||||
** - 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";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchNotebook
|
||||
** sql_workbench_notebook
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotebookTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
return (text(base(config, SqlWorkbenchNotebook.TABLE_NAME, "SQL Notebook", "menu_book", SqlWorkbenchNotebook.class), "description", "parametersJson")
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchNotebook.TABLE_NAME, "SQL Notebook", "menu_book", SqlWorkbenchNotebook.class), "description", "parametersJson")
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("title"));
|
||||
.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, "Shares", "share"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SNAPSHOT, "Snapshots (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("defaultBackendName").setPossibleValueSourceName(config.applyPrefix(WorkbenchBackendPossibleValueProvider.NAME));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchCell
|
||||
** 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");
|
||||
table.setIsHidden(true);
|
||||
table.withUniqueKey(new UniqueKey("notebookId", "cellKey"));
|
||||
readOnly(table);
|
||||
return (linkNotebook(config, table));
|
||||
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("backendName").setPossibleValueSourceName(config.applyPrefix(WorkbenchBackendPossibleValueProvider.NAME));
|
||||
table.getField("cellKey").setIsEditable(false);
|
||||
table.getField("cellKey").setIsRequired(false);
|
||||
table.getField("cellType").setPossibleValueSourceName(config.applyPrefix(CellType.NAME));
|
||||
table.getField("cellType").setIsRequired(false);
|
||||
table.getField("cellType").setDefaultValue(CellType.SQL.getPossibleValueId());
|
||||
table.getField("content").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("sql")));
|
||||
return (customize(linkNotebook(config, table), CellTableCustomizer.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchNotebookVersion
|
||||
** 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");
|
||||
table.setIsHidden(true);
|
||||
table.withUniqueKey(new UniqueKey("notebookId", "versionNumber"));
|
||||
readOnly(table);
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchResultSnapshot
|
||||
** 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");
|
||||
table.setIsHidden(true);
|
||||
readOnly(table);
|
||||
return (linkNotebook(config, table));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchExecution (the audit)
|
||||
** 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");
|
||||
table.withUniqueKey(new UniqueKey("executionUuid"));
|
||||
readOnly(table);
|
||||
table.withRecordLabelFormat("%s %s").withRecordLabelFields("mode", "executionUuid");
|
||||
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));
|
||||
table.getField("parametersJson").withIsEditable(false)
|
||||
.withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
|
||||
appendOnly(table);
|
||||
return (table);
|
||||
}
|
||||
|
||||
@@ -122,6 +248,8 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
|
||||
/*******************************************************************************
|
||||
** 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)
|
||||
{
|
||||
@@ -131,11 +259,101 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All five tables.
|
||||
** 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), buildExecutionTable(config)));
|
||||
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("Shares").withCanAddChildRecord(true)
|
||||
.withOnlyIncludeFieldNames(List.of("id", "audienceType", "audienceId", "createdByName", "createDate"))
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_SNAPSHOT)))
|
||||
.withLabel("Snapshots (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);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +369,7 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
.withIcon(new QIcon(icon))
|
||||
.withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id")
|
||||
.withPermissionRules(config.getAdminPermissionRules())
|
||||
.withPermissionRules(config.getTablePermissionRules())
|
||||
.withFieldsFromEntity(entity);
|
||||
return (table);
|
||||
}
|
||||
@@ -159,11 +377,91 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the qbit (as the system user) writes this table.
|
||||
** Owner lock: the session's sql_workbench_user value must equal the field
|
||||
** (read and write).
|
||||
*******************************************************************************/
|
||||
private static void readOnly(QTableMetaData table)
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.BasicCustomPossibleValueProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Backend choices use the same live allow/deny filtering as the Runner.
|
||||
*******************************************************************************/
|
||||
public class WorkbenchBackendPossibleValueProvider extends BasicCustomPossibleValueProvider<WorkbenchBackends.Target, String>
|
||||
{
|
||||
public static final String NAME = "sql_workbench_backend";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QPossibleValue<String> makePossibleValue(WorkbenchBackends.Target target)
|
||||
{
|
||||
return (new QPossibleValue<>(target.name(), target.name()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown or excluded stored values have no selectable translation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected WorkbenchBackends.Target getSourceObject(Serializable id) throws QException
|
||||
{
|
||||
return (getAllSourceObjects().stream().filter(target -> target.name().equals(id)).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected List<WorkbenchBackends.Target> getAllSourceObjects() throws QException
|
||||
{
|
||||
return (new WorkbenchBackends(QContext.getQInstance(), SqlWorkbenchRuntime.get()).listTargets());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The supported notebook cell types.
|
||||
*******************************************************************************/
|
||||
public enum CellType implements PossibleValueEnum<String>
|
||||
{
|
||||
SQL,
|
||||
MARKDOWN;
|
||||
|
||||
public static final String NAME = "sql_workbench_cell_type";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (this == SQL ? "SQL" : "Markdown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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.4.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCell extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchCell";
|
||||
public static final String TABLE_NAME = "sql_workbench_cell";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.4 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchCell";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -31,7 +31,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchExecution extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchExecution";
|
||||
public static final String TABLE_NAME = "sql_workbench_execution";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.4 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchExecution";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -30,7 +30,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebook extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchNotebook";
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.4 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebook";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* 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.4 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+8
-1
@@ -29,7 +29,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookVersion extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchNotebookVersion";
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook_version";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.4 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebookVersion";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
+8
-1
@@ -30,7 +30,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchResultSnapshot extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchResultSnapshot";
|
||||
public static final String TABLE_NAME = "sql_workbench_result_snapshot";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.4 (owner rule: lowercase snake_case,
|
||||
** prefixed with the qbit name). Kept for migrating stored references only.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchResultSnapshot";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -18,26 +18,43 @@ package com.kingsrook.qbits.sqlworkbench.notebook;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
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.CellType;
|
||||
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;
|
||||
@@ -46,46 +63,35 @@ 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;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
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.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebooks, their cells, versions and pinned result snapshots.
|
||||
** Notebooks, their cells, versions, pinned result snapshots and shares, for
|
||||
** the notebook UI.
|
||||
**
|
||||
** Visibility: a caller reads their own notebooks and every SHARED one; only
|
||||
** the owner edits, deletes, restores and pins; publishing needs the share
|
||||
** permission and ownership; anyone with access can duplicate what they can
|
||||
** read.
|
||||
** 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.
|
||||
**
|
||||
** These checks are the boundary: the tables themselves are guarded by the
|
||||
** admin permission (sqlWorkbenchAdmin.hasAccess), which ordinary users do
|
||||
** not hold, so the generic QQQ table UI / API cannot reach other users'
|
||||
** notebooks. Every read and write here runs as the system user (SystemUser)
|
||||
** and every public method checks the Caller first (readable / owned /
|
||||
** canShare) before it reads content or writes anything.
|
||||
**
|
||||
** Saves replace a notebook's cells and append a version (full JSON), in one
|
||||
** transaction; versions beyond notebookVersionRetention are pruned.
|
||||
** 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 String CELL_SQL = CellType.SQL.getPossibleValueId();
|
||||
public static final String CELL_MARKDOWN = CellType.MARKDOWN.getPossibleValueId();
|
||||
public static final int MAX_CELLS = 200;
|
||||
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
@@ -115,24 +121,46 @@ public class NotebookService
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook summaries: scope "mine" (default) or "shared".
|
||||
** 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
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
prepare();
|
||||
QQueryFilter filter;
|
||||
if("shared".equalsIgnoreCase(scope))
|
||||
{
|
||||
QQueryFilter filter = "shared".equalsIgnoreCase(scope)
|
||||
? new QQueryFilter(new QFilterCriteria("visibility", QCriteriaOperator.EQUALS, VISIBILITY_SHARED))
|
||||
: 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))
|
||||
Set<Serializable> ids = sharedWithCallerNotebookIds();
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
result.add(summary(new SqlWorkbenchNotebook(record), caller));
|
||||
return (new ArrayList<>());
|
||||
}
|
||||
return (result);
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -142,46 +170,38 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> get(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = readable(id, caller);
|
||||
Map<String, Object> view = summary(notebook, caller);
|
||||
view.put("parameters", parseParameters(notebook.getParametersJson()));
|
||||
view.put("cells", cells(notebook.getId()).stream().map(NotebookService::cellView).toList());
|
||||
return (view);
|
||||
}));
|
||||
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).
|
||||
** 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
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
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))))
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook()
|
||||
.withOwnerUserId(caller.userId())
|
||||
.withOwnerName(caller.userName())
|
||||
.withVisibility(VISIBILITY_PERSONAL)
|
||||
.withCurrentVersion(0)
|
||||
.withCreateDate(now);
|
||||
applyFields(notebook, body, true);
|
||||
List<SqlWorkbenchCell> cells = parseCells(body.get("cells"));
|
||||
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
notebook.setModifyDate(now);
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withRecordEntity(notebook).withTransaction(transaction));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
notebook.setId(output.getRecords().get(0).getValueInteger("id"));
|
||||
saveContent(notebook, cells, caller, (String) body.get("comment"), transaction);
|
||||
commit(transaction);
|
||||
}
|
||||
return (get(notebook.getId(), caller));
|
||||
}));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -191,64 +211,64 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> update(Integer id, Map<String, Object> body, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
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))))
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = owned(id, caller);
|
||||
applyFields(notebook, body, false);
|
||||
List<SqlWorkbenchCell> cells = body.containsKey("cells") ? parseCells(body.get("cells")) : cells(id);
|
||||
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
saveContent(notebook, cells, caller, (String) body.get("comment"), transaction);
|
||||
commit(transaction);
|
||||
}
|
||||
pruneVersions(id);
|
||||
return (get(id, caller));
|
||||
}));
|
||||
saveContent(notebook, cells, (String) body.get("comment"), transaction);
|
||||
commit(transaction);
|
||||
}
|
||||
return (get(id, caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a notebook and everything under it.
|
||||
** Delete a notebook; its table customizer deletes everything under it.
|
||||
*******************************************************************************/
|
||||
public void delete(Integer id, Caller caller) throws QException
|
||||
{
|
||||
SystemUser.run(() ->
|
||||
prepare();
|
||||
owned(id, caller);
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
owned(id, caller);
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchResultSnapshot.TABLE_NAME))
|
||||
{
|
||||
new DeleteAction().execute(new DeleteInput(table(child)).withQueryFilter(byNotebook(id)).withTransaction(transaction));
|
||||
}
|
||||
new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withPrimaryKeys(List.of(id)).withTransaction(transaction));
|
||||
commit(transaction);
|
||||
}
|
||||
});
|
||||
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.
|
||||
** 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
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
prepare();
|
||||
if(!caller.canShare())
|
||||
{
|
||||
if(!caller.canShare())
|
||||
{
|
||||
throw (new QPermissionDeniedException("Publishing to the shared library needs the " + config.getSharePermissionName() + " permission."));
|
||||
}
|
||||
SqlWorkbenchNotebook notebook = owned(id, caller);
|
||||
new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withRecord(new QRecord()
|
||||
.withValue("id", notebook.getId())
|
||||
.withValue("visibility", shared ? VISIBILITY_SHARED : VISIBILITY_PERSONAL)
|
||||
.withValue("modifyDate", runtime.getClock().instant())));
|
||||
return (get(id, caller));
|
||||
}));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -258,25 +278,23 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> duplicate(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
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))
|
||||
{
|
||||
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 : cells(id))
|
||||
{
|
||||
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));
|
||||
}));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -286,23 +304,21 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> versions(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
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))
|
||||
{
|
||||
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);
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -312,18 +328,16 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> version(Integer id, Integer versionNumber, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
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);
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -333,14 +347,12 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> restore(Integer id, Integer versionNumber, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
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));
|
||||
}));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -350,16 +362,14 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> snapshots(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
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))))
|
||||
{
|
||||
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);
|
||||
}));
|
||||
result.add(snapshotView(new SqlWorkbenchResultSnapshot(record)));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
@@ -369,38 +379,35 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> createSnapshot(Integer id, Map<String, Object> body, Caller caller, String sqlText) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
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)).withRecordEntity(snapshot));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
snapshot.setId(output.getRecords().get(0).getValueInteger("id"));
|
||||
return (snapshotView(snapshot));
|
||||
}));
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -410,28 +417,33 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
public void deleteSnapshot(Integer id, Integer snapshotId, Caller caller) throws QException
|
||||
{
|
||||
SystemUser.run(() ->
|
||||
prepare();
|
||||
owned(id, caller);
|
||||
QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("id", QCriteriaOperator.EQUALS, snapshotId));
|
||||
if(QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), filter).isEmpty())
|
||||
{
|
||||
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."));
|
||||
}
|
||||
new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)).withPrimaryKeys(List.of(snapshotId)));
|
||||
});
|
||||
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: personal notebooks of
|
||||
** others do not exist for the caller).
|
||||
** 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 || !(Objects.equals(notebook.getOwnerUserId(), caller.userId()) || VISIBILITY_SHARED.equals(notebook.getVisibility())))
|
||||
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."));
|
||||
}
|
||||
@@ -441,7 +453,7 @@ public class NotebookService
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook the caller owns: 404 if unreadable, 403 if shared but not
|
||||
** A notebook the caller owns: 404 if unreadable, 403 if readable but not
|
||||
** theirs.
|
||||
*******************************************************************************/
|
||||
SqlWorkbenchNotebook owned(Integer id, Caller caller) throws QException
|
||||
@@ -457,33 +469,37 @@ public class NotebookService
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** The notebook, as the caller's session can see it (or null).
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchNotebook find(Integer id) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
if(id == null)
|
||||
{
|
||||
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)));
|
||||
}));
|
||||
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 List<SqlWorkbenchCell> cells(Integer notebookId) throws QException
|
||||
private Set<Serializable> sharedWithCallerNotebookIds() throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
Set<Serializable> ids = new LinkedHashSet<>();
|
||||
List<String> audienceKeys = SqlWorkbenchSecurity.sessionAudienceKeys(config);
|
||||
if(audienceKeys.isEmpty())
|
||||
{
|
||||
QQueryFilter filter = byNotebook(notebookId).withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id"));
|
||||
return (QueryAction.execute(table(SqlWorkbenchCell.TABLE_NAME), SqlWorkbenchCell.class, filter));
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -493,16 +509,13 @@ public class NotebookService
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchNotebookVersion findVersion(Integer id, Integer versionNumber) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("versionNumber", QCriteriaOperator.EQUALS, versionNumber));
|
||||
List<QRecord> records = QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter);
|
||||
if(records.isEmpty())
|
||||
{
|
||||
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)));
|
||||
}));
|
||||
throw (new QNotFoundException("Version not found."));
|
||||
}
|
||||
return (new SqlWorkbenchNotebookVersion(records.get(0)));
|
||||
}
|
||||
|
||||
|
||||
@@ -518,81 +531,46 @@ public class NotebookService
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write the notebook row (version + 1), replace its cells, append a
|
||||
** version - all in the given transaction.
|
||||
** 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, Caller caller, String comment, QBackendTransaction transaction) throws QException
|
||||
private void saveContent(SqlWorkbenchNotebook notebook, List<SqlWorkbenchCell> cells, String comment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
int version = (notebook.getCurrentVersion() == null ? 0 : notebook.getCurrentVersion()) + 1;
|
||||
notebook.setCurrentVersion(version);
|
||||
notebook.setModifyDate(now);
|
||||
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));
|
||||
|
||||
new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME)).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())
|
||||
.withValue("currentVersion", version)
|
||||
.withValue("modifyDate", now)));
|
||||
DeleteOutput deleted = new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchCell.TABLE_NAME))
|
||||
.withFlag(SqlWorkbenchActionFlag.NOTEBOOK_SAVE)
|
||||
.withQueryFilter(byNotebook(notebook.getId()))
|
||||
.withTransaction(transaction));
|
||||
throwIfErrors(deleted.getRecordsWithErrors());
|
||||
|
||||
new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchCell.TABLE_NAME)).withQueryFilter(byNotebook(notebook.getId())).withTransaction(transaction));
|
||||
for(int i = 0; i < cells.size(); i++)
|
||||
{
|
||||
cells.get(i).withId(null).withNotebookId(notebook.getId()).withSortOrder(i).withCreateDate(now).withModifyDate(now);
|
||||
cells.get(i).withId(null).withNotebookId(notebook.getId()).withSortOrder(i);
|
||||
}
|
||||
if(!cells.isEmpty())
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchCell.TABLE_NAME)).withRecordEntities(cells).withTransaction(transaction));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> content = new LinkedHashMap<>();
|
||||
content.put("title", notebook.getTitle());
|
||||
content.put("description", notebook.getDescription());
|
||||
content.put("defaultBackendName", notebook.getDefaultBackendName());
|
||||
content.put("parameters", parseParameters(notebook.getParametersJson()));
|
||||
content.put("cells", cells.stream().map(NotebookService::cellView).toList());
|
||||
|
||||
SqlWorkbenchNotebookVersion versionRow = new SqlWorkbenchNotebookVersion()
|
||||
.withNotebookId(notebook.getId())
|
||||
.withVersionNumber(version)
|
||||
.withContentJson(JsonUtils.toJson(content))
|
||||
.withSavedByUserId(caller.userId())
|
||||
.withSavedByName(caller.userName())
|
||||
.withComment(truncate(comment, 255))
|
||||
.withCreateDate(now);
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME)).withRecordEntity(versionRow).withTransaction(transaction));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete versions beyond the retention, oldest first.
|
||||
*******************************************************************************/
|
||||
private void pruneVersions(Integer id) throws QException
|
||||
{
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
QueryInput input = new QueryInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME))
|
||||
.withFilter(byNotebook(id).withOrderBy(new QFilterOrderBy("versionNumber", false)));
|
||||
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(table(SqlWorkbenchNotebookVersion.TABLE_NAME)).withPrimaryKeys(stale));
|
||||
});
|
||||
NotebookVersioning.appendVersion(config, runtime.getClock(), notebook.getId(), comment, transaction);
|
||||
}
|
||||
|
||||
|
||||
@@ -655,26 +633,22 @@ public class NotebookService
|
||||
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)
|
||||
@@ -789,13 +763,32 @@ public class NotebookService
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Throw the first error of a record (a QPermissionDeniedException for
|
||||
** permission errors, so the notebook UI answers 403).
|
||||
*******************************************************************************/
|
||||
private static void throwIfErrors(QRecord record) throws QException
|
||||
static void throwIfErrors(QRecord record) throws QException
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
if(record != null && CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
throw (new QException("Could not save: " + record.getErrors().get(0).getMessage()));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,5 +814,4 @@ public class NotebookService
|
||||
{
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max - 1) + "…");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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") + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
bundle: qbit-sql-workbench
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:sqlWorkbench;slot:help
|
||||
summary: 'A SQL notebook for your databases: write queries, run them read-only, and review every run afterwards.'
|
||||
content: |
|
||||
The home page is a dashboard of your notebooks and recent runs (admins see everyone's). Use **{{label:sqlWorkbenchRunner}}** to write and run SQL, **{{label:sql_workbench_notebook}}** to manage saved notebooks, and **{{label:sql_workbench_execution}}** to see who ran what.
|
||||
|
||||
**Safe by default.** Runs are read-only and one statement at a time. Writing to the database needs write mode enabled by your administrator, the separate write permission and two steps: a dry run that is rolled back, then a confirmation.
|
||||
|
||||
**Who sees what.** You see your own notebooks, notebooks shared with you, and your own runs. Administrators see everything.
|
||||
|
||||
Raw SQL bypasses normal application record rules, history and search updates. Use application processes for business changes. Every run is recorded in the execution audit.
|
||||
- key: app:sqlWorkbenchRunner;slot:help
|
||||
summary: 'The notebook editor: write SQL and notes in cells, run them, and see results, charts and query plans.'
|
||||
content: |
|
||||
Open a notebook from **{{label:sql_workbench_notebook}}** (the **Open in Runner** link) or start a new one here.
|
||||
|
||||
- A notebook is a list of cells. A SQL cell holds a query, a Markdown cell holds notes.
|
||||
- Each SQL cell runs against one database; read-only is the default.
|
||||
- Named parameters written as :name in the SQL are always bound as values, never pasted into the text.
|
||||
- Saving creates a new version. Results can be pinned as snapshots and exported.
|
||||
|
||||
Only one statement runs per cell in read-only mode, and your administrator sets the limit on simultaneous runs.
|
||||
- key: table:sql_workbench_notebook;slot:help
|
||||
summary: 'A saved SQL notebook: its title, default database, parameters and cells, owned by one person.'
|
||||
content: |
|
||||
**How rows get created:** You create a notebook in the Runner or with New here. It starts with your own content.
|
||||
|
||||
Notebooks are personal until the owner shares them. You can read your own notebooks and ones shared with you; only the owner (or an administrator) can edit or delete one.
|
||||
|
||||
The record view lists the notebook's **Cells**, **Shares**, **Snapshots (pinned results)**, **Versions** and **Runs**, and has an **Open in Runner** link. Actions: **{{label:sqlWorkbenchShareNotebook}}** and **{{label:sqlWorkbenchDuplicateNotebook}}**.
|
||||
|
||||
Creating a notebook here makes you the owner and saves version 1. Deleting a notebook also deletes its cells, versions, pinned results and shares. Past runs stay in **{{label:sql_workbench_execution}}**.
|
||||
- key: table:sql_workbench_notebook;section:identity
|
||||
summary: The notebook's title, description, owner and who can read it.
|
||||
- key: table:sql_workbench_notebook;section:runner
|
||||
summary: A button to open this notebook in the Runner editor.
|
||||
- key: table:sql_workbench_notebook;section:details
|
||||
summary: The default database and the named parameters the notebook's SQL can use.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_cell
|
||||
summary: The SQL and Markdown cells of this notebook, in order.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_notebook_share
|
||||
summary: Who this notebook is shared with, read-only.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_result_snapshot
|
||||
summary: Query results pinned from this notebook so they stay viewable later.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_notebook_version
|
||||
summary: The saved history of this notebook, newest first (the latest 25 are shown).
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_execution
|
||||
summary: The most recent runs of this notebook (latest 25), taken from the execution audit.
|
||||
- key: table:sql_workbench_notebook;section:system
|
||||
summary: Owner id, current version number and created and changed dates, all filled in automatically.
|
||||
- key: table:sql_workbench_notebook;field:title
|
||||
summary: The name shown in the notebook list and the Runner.
|
||||
content: |
|
||||
Required. Surrounding spaces are removed and it cannot be blank. Up to {{maxLength:sql_workbench_notebook.title}} characters.
|
||||
- key: table:sql_workbench_notebook;field:description
|
||||
summary: A short note about what the notebook is for.
|
||||
- key: table:sql_workbench_notebook;field:defaultBackendName
|
||||
summary: The database new SQL cells run against unless a cell picks its own.
|
||||
content: |
|
||||
Leave it blank to choose per cell. Your administrator controls which databases are available.
|
||||
- key: table:sql_workbench_notebook;field:parametersJson
|
||||
summary: Named parameters for the SQL, as a JSON list with a name, type, default value and label for each.
|
||||
content: |
|
||||
Reference a parameter in SQL as :name. Values are always bound safely, never pasted into the statement. The Runner has a parameters panel that edits this for you; if you type it by hand it must be a valid JSON list or the save is refused.
|
||||
- key: table:sql_workbench_notebook;field:ownerUserId
|
||||
summary: The sign-in id of the owner, set automatically from whoever created the notebook.
|
||||
content: |
|
||||
Only an administrator can change it, which transfers the notebook to someone else (the owner name then follows the new id).
|
||||
- key: table:sql_workbench_notebook;field:ownerName
|
||||
summary: The owner's display name, copied from their account when the notebook was created.
|
||||
- key: table:sql_workbench_notebook;field:currentVersion
|
||||
summary: The number of the newest saved version, increased by every save.
|
||||
- key: table:sql_workbench_notebook;field:visibility
|
||||
summary: Whether the notebook is in the shared library or limited to its owner, administrators and other share audiences.
|
||||
content: |
|
||||
- **PERSONAL**: not published to everyone.
|
||||
- **SHARED**: shared with Everyone (the shared library).
|
||||
|
||||
You cannot edit this directly. It follows the notebook's shares: add an Everyone share to publish it, remove that share to make it personal again.
|
||||
- key: table:sql_workbench_notebook_share;slot:help
|
||||
summary: One person, group or everyone who may read a notebook, read-only.
|
||||
content: |
|
||||
**How rows get created:** You use Share Notebook or add a share from the notebook's Shares list.
|
||||
|
||||
A share never lets anyone edit the notebook; it only lets them open and run it. Add one from a notebook's **Shares** list or with the **{{label:sqlWorkbenchShareNotebook}}** action. You need the share permission, and only the notebook's owner or an administrator can add or remove shares.
|
||||
|
||||
Share with Everyone to make the notebook available in the shared library.
|
||||
- key: table:sql_workbench_notebook_share;section:identity
|
||||
summary: The notebook and who it is shared with.
|
||||
- key: table:sql_workbench_notebook_share;section:system
|
||||
summary: The matching key, who added the share and when, all filled in automatically.
|
||||
- key: table:sql_workbench_notebook_share;field:audienceType
|
||||
summary: Whether the share goes to one person, a group, or everyone.
|
||||
content: |
|
||||
- **User**: one person, named in User or Group.
|
||||
- **Group**: a sign-in group, named in User or Group.
|
||||
- **Everyone (shared library)**: all workbench users; no name needed. This publishes the notebook to the shared library.
|
||||
- key: table:sql_workbench_notebook_share;field:audienceId
|
||||
summary: The user id or group name to share with; leave blank for Everyone.
|
||||
content: |
|
||||
Required for User and Group shares. Matching ignores upper and lower case and surrounding spaces, so Admins and admins are the same group.
|
||||
- key: table:sql_workbench_notebook_share;field:notebookId
|
||||
summary: The notebook being shared. It cannot be moved to another notebook after the share is created.
|
||||
- key: table:sql_workbench_notebook_share;field:audienceKey
|
||||
summary: The normalized form of the audience, such as user:name, group:name or all, used for access checks.
|
||||
content: |
|
||||
Filled in automatically from the type and name.
|
||||
- key: table:sql_workbench_notebook_share;field:createdByUserId
|
||||
summary: The sign-in id of the person who added the share, set automatically.
|
||||
- key: table:sql_workbench_notebook_share;field:createdByName
|
||||
summary: The display name of the person who added the share, set automatically.
|
||||
- key: table:sql_workbench_cell;slot:help
|
||||
summary: 'One block of a notebook: a SQL query or a Markdown note.'
|
||||
content: |
|
||||
**How rows get created:** You add SQL or Markdown cells to a notebook in the Runner or from its Cells list.
|
||||
|
||||
Cells are normally edited in the Runner, but you can add, edit, reorder and delete them here. A notebook can hold at most 200 cells, and your administrator sets the SQL length limit.
|
||||
|
||||
Every add, edit or delete saves a new notebook version, just like saving in the Runner. A new cell is added at the end with a generated key. Only the notebook's owner or an administrator can change cells.
|
||||
- key: table:sql_workbench_cell;section:identity
|
||||
summary: Which notebook the cell belongs to, its position, type and title.
|
||||
- key: table:sql_workbench_cell;section:content
|
||||
summary: The SQL or Markdown text and the database the cell runs against.
|
||||
- key: table:sql_workbench_cell;section:system
|
||||
summary: The cell's generated key and created and changed dates, filled in automatically.
|
||||
- key: table:sql_workbench_cell;field:notebookId
|
||||
summary: The notebook this cell belongs to. A cell cannot be moved to a different notebook.
|
||||
- key: table:sql_workbench_cell;field:cellKey
|
||||
summary: A generated unique key that stays with the cell. Pinned results and run history refer to it.
|
||||
content: |
|
||||
Set automatically when the cell is created and it cannot be changed.
|
||||
- key: table:sql_workbench_cell;field:sortOrder
|
||||
summary: The cell's position in the notebook, lowest first.
|
||||
content: |
|
||||
Left blank on a new cell it is placed at the end. Change the numbers to reorder cells.
|
||||
- key: table:sql_workbench_cell;field:cellType
|
||||
summary: Whether the cell is runnable SQL or a Markdown note.
|
||||
content: |
|
||||
- **SQL**: a query that you can run. Default: {{default:sql_workbench_cell.cellType}}.
|
||||
- **Markdown**: formatted notes that are displayed, never run.
|
||||
- key: table:sql_workbench_cell;field:title
|
||||
summary: "An optional heading for the cell."
|
||||
content: |
|
||||
Give the cell an optional heading of up to {{maxLength:sql_workbench_cell.title}} characters.
|
||||
- key: table:sql_workbench_cell;field:content
|
||||
summary: The SQL text or Markdown. SQL can use :name parameters defined on the notebook.
|
||||
content: |
|
||||
Your administrator sets the maximum SQL length.
|
||||
- key: table:sql_workbench_cell;field:backendName
|
||||
summary: The database this SQL cell runs against; blank uses the notebook's default.
|
||||
- key: table:sql_workbench_notebook_version;slot:help
|
||||
summary: 'A read-only history entry: the full content of a notebook as it was at one save.'
|
||||
content: |
|
||||
**How rows get created:** The workbench saves a version when you create or change a notebook or its cells.
|
||||
|
||||
A new version is written each time the notebook is saved, a cell is added, edited or removed, or an older version is restored. It holds the title, description, parameters and all cells. You cannot edit or delete versions yourself. Older versions are pruned according to your administrator's retention setting.
|
||||
|
||||
To go back, run **{{label:sqlWorkbenchRestoreNotebookVersion}}** on the version you want. That saves it as a new, latest version, while retained versions remain available.
|
||||
- key: table:sql_workbench_notebook_version;section:identity
|
||||
summary: Which notebook and version number this is, and the note saved with it.
|
||||
- key: table:sql_workbench_notebook_version;section:content
|
||||
summary: The saved notebook content as JSON.
|
||||
- key: table:sql_workbench_notebook_version;section:system
|
||||
summary: Who saved this version and when, filled in automatically.
|
||||
- key: table:sql_workbench_notebook_version;field:notebookId
|
||||
summary: The notebook this version belongs to.
|
||||
- key: table:sql_workbench_notebook_version;field:versionNumber
|
||||
summary: The version's number, counting up from 1 within its notebook.
|
||||
- key: table:sql_workbench_notebook_version;field:comment
|
||||
summary: A short note on why the version was saved, such as Created, Cell added, Cell edited or Restored version 3.
|
||||
- key: table:sql_workbench_notebook_version;field:contentJson
|
||||
summary: The notebook's title, description, default database, parameters and every cell, saved as JSON.
|
||||
- key: table:sql_workbench_notebook_version;field:savedByUserId
|
||||
summary: The sign-in id of the person who saved this version.
|
||||
- key: table:sql_workbench_notebook_version;field:savedByName
|
||||
summary: The display name of the person who saved this version.
|
||||
- key: table:sql_workbench_result_snapshot;slot:help
|
||||
summary: A query result pinned from a notebook so you can look at it later without re-running the SQL.
|
||||
content: |
|
||||
**How rows get created:** You run a query in the Runner and pin its result to your notebook.
|
||||
|
||||
Pin a result from the Runner after running a cell. A snapshot keeps the column list, rows up to the configured snapshot limit, the SQL that produced it and the database it came from. It contains real query data, so it is visible only to the notebook's readers and administrators.
|
||||
|
||||
You cannot add snapshots here, and the only thing you can edit is the **{{label:sql_workbench_result_snapshot.label}}**. Delete a snapshot you no longer need.
|
||||
- key: table:sql_workbench_result_snapshot;section:identity
|
||||
summary: Which notebook and cell the result came from and its label.
|
||||
- key: table:sql_workbench_result_snapshot;section:result
|
||||
summary: The pinned rows, the SQL and database that produced them, and whether the result was cut short.
|
||||
- key: table:sql_workbench_result_snapshot;section:system
|
||||
summary: Who pinned the result and when, filled in automatically.
|
||||
- key: table:sql_workbench_result_snapshot;field:notebookId
|
||||
summary: The notebook the result was pinned from.
|
||||
- key: table:sql_workbench_result_snapshot;field:cellKey
|
||||
summary: The key of the notebook cell that produced the result.
|
||||
- key: table:sql_workbench_result_snapshot;field:label
|
||||
summary: "A name you give the pinned result to find it again."
|
||||
content: |
|
||||
A name you give the pinned result to find it again. This is the only editable field. Up to {{maxLength:sql_workbench_result_snapshot.label}} characters.
|
||||
- key: table:sql_workbench_result_snapshot;field:backendName
|
||||
summary: The database the SQL ran against, recorded when the result was pinned.
|
||||
- key: table:sql_workbench_result_snapshot;field:executionUuid
|
||||
summary: The id of the run that produced this result; it matches a row in SQL Execution Audit.
|
||||
- key: table:sql_workbench_result_snapshot;field:sqlText
|
||||
summary: The SQL that produced the pinned result, as it was when you pinned it.
|
||||
- key: table:sql_workbench_result_snapshot;field:resultJson
|
||||
summary: The pinned columns and rows as JSON, up to the configured snapshot limit.
|
||||
- key: table:sql_workbench_result_snapshot;field:rowCount
|
||||
summary: How many rows the snapshot holds.
|
||||
- key: table:sql_workbench_result_snapshot;field:truncated
|
||||
summary: Yes when the result had more rows than could be kept, so the snapshot holds only the first part.
|
||||
- key: table:sql_workbench_result_snapshot;field:createdByUserId
|
||||
summary: The sign-in id of the person who pinned the result.
|
||||
- key: table:sql_workbench_execution;slot:help
|
||||
summary: 'The audit log of SQL runs: who ran what, where, how long it took and how it ended.'
|
||||
content: |
|
||||
**How rows get created:** The workbench records runs before executing SQL, including rejected SQL attempts.
|
||||
|
||||
Every run is recorded before the SQL starts, and attempts that were refused are logged too, so this is the place to answer "who changed or read this?". Rows are added and updated by the workbench only; you cannot edit or delete them. Your administrator can configure automatic removal of older completed runs.
|
||||
|
||||
You see your own runs; administrators see everyone's. The log keeps the SQL text and parameter values, but never the result rows.
|
||||
|
||||
Use the filters on **{{label:sql_workbench_execution.status}}**, **{{label:sql_workbench_execution.mode}}**, **User** and **{{label:sql_workbench_execution.startTime}}** to narrow it down, or open a run from a notebook's **Runs** list.
|
||||
- key: table:sql_workbench_execution;section:identity
|
||||
summary: The run's id, mode, outcome and database.
|
||||
- key: table:sql_workbench_execution;section:run
|
||||
summary: Who ran it, when, how long it took, and how many rows came back or changed.
|
||||
- key: table:sql_workbench_execution;section:sql
|
||||
summary: The SQL text, its fingerprint, the parameter values used and any error.
|
||||
- key: table:sql_workbench_execution;section:system
|
||||
summary: When the audit row was created, filled in automatically.
|
||||
- key: table:sql_workbench_execution;field:executionUuid
|
||||
summary: A unique id for the run, also used to fetch its results and match pinned snapshots.
|
||||
- key: table:sql_workbench_execution;field:mode
|
||||
summary: How the SQL was run.
|
||||
content: |
|
||||
- **READ_ONLY**: a normal read-only run.
|
||||
- **WRITE_PREVIEW**: a dry run of a write, rolled back so you can see the affected-row counts.
|
||||
- **WRITE_COMMIT**: the confirmed write, committed.
|
||||
- **EXPLAIN**: a query plan without running the query.
|
||||
- **EXPLAIN_ANALYZE**: a query plan that actually runs the query (needs the write permission).
|
||||
- key: table:sql_workbench_execution;field:status
|
||||
summary: How the run ended, or Running while it is still going.
|
||||
content: |
|
||||
- **RUNNING**: in progress. A run that never finishes may stay here.
|
||||
- **SUCCEEDED**: finished normally.
|
||||
- **FAILED**: the database returned an error (see Error).
|
||||
- **CANCELLED**: someone stopped it.
|
||||
- **TIMED_OUT**: it went over the time limit configured by your administrator.
|
||||
- **REJECTED**: refused before running, for example the SQL was not a plain read.
|
||||
- **ROLLED_BACK**: a write preview, undone on purpose.
|
||||
- **DRIFTED**: the data changed between preview and confirm, so the write was rolled back.
|
||||
- key: table:sql_workbench_execution;field:backendName
|
||||
summary: The database the SQL ran against.
|
||||
- key: table:sql_workbench_execution;field:userId
|
||||
summary: The sign-in id of the person who ran it.
|
||||
- key: table:sql_workbench_execution;field:userName
|
||||
summary: The display name of the person who ran it, as it was at the time.
|
||||
- key: table:sql_workbench_execution;field:startTime
|
||||
summary: When the run started.
|
||||
- key: table:sql_workbench_execution;field:endTime
|
||||
summary: When the run finished; blank while it is still running.
|
||||
- key: table:sql_workbench_execution;field:durationMs
|
||||
summary: How long the run took, in milliseconds.
|
||||
- key: table:sql_workbench_execution;field:rowsReturned
|
||||
summary: How many result rows a read returned, up to the configured row limit.
|
||||
- key: table:sql_workbench_execution;field:rowsAffected
|
||||
summary: How many rows a write changed or, for a preview, would change.
|
||||
- key: table:sql_workbench_execution;field:truncated
|
||||
summary: Yes when the result hit the row or size limit and was cut short.
|
||||
- key: table:sql_workbench_execution;field:statementCount
|
||||
summary: How many SQL statements the run contained. Read-only runs allow one.
|
||||
- key: table:sql_workbench_execution;field:statementKinds
|
||||
summary: The kinds of statement the run was classified as, separated by commas.
|
||||
- key: table:sql_workbench_execution;field:notebookId
|
||||
summary: The notebook the run came from; blank for SQL run outside a notebook.
|
||||
- key: table:sql_workbench_execution;field:cellKey
|
||||
summary: The key of the notebook cell that was run, if any.
|
||||
- key: table:sql_workbench_execution;field:sqlText
|
||||
summary: The exact SQL that was run (or refused). It can contain sensitive literals, so treat it as confidential.
|
||||
- key: table:sql_workbench_execution;field:sqlHash
|
||||
summary: A fingerprint of the database, SQL and parameters, useful for spotting repeated queries.
|
||||
- key: table:sql_workbench_execution;field:parametersJson
|
||||
summary: The values bound to the named parameters for this run, stored as written.
|
||||
- key: table:sql_workbench_execution;field:errorMessage
|
||||
summary: The error text when the run failed, timed out or was rejected, cut off at 4,000 characters.
|
||||
- key: table:sql_workbench_execution;field:clientIp
|
||||
summary: The network address the request came from, when it was available.
|
||||
- key: process:sqlWorkbenchShareNotebook;slot:help
|
||||
summary: Share the selected notebook, read-only, with one user, one group or everyone who can use the SQL Workbench.
|
||||
content: |
|
||||
Select one notebook, choose who to share with, and run. Recipients can open the notebook but cannot change it; to edit, they duplicate it into their own copy. Only the owner or an administrator changes the original.
|
||||
|
||||
Sharing with a **User** gives that person read access. Sharing with **Everyone** puts it in the shared library. Sharing needs the notebook share permission. To stop sharing, delete the share under **Shares** on the notebook.
|
||||
- key: process:sqlWorkbenchShareNotebook;field:audienceType
|
||||
summary: 'Who gets access: one user, one group, or everyone.'
|
||||
content: |
|
||||
Choose **User** or **Group** and fill in the next field, or **Everyone (shared library)** to share with every workbench user.
|
||||
- key: process:sqlWorkbenchShareNotebook;field:audienceId
|
||||
summary: The user name or group name to share with; leave blank when sharing with everyone.
|
||||
content: |
|
||||
Names are not case sensitive. Required for User and Group.
|
||||
- key: process:sqlWorkbenchDuplicateNotebook;slot:help
|
||||
summary: Make your own copy of a notebook you can open, with all its cells, parameters and default database.
|
||||
content: |
|
||||
The copy is titled "Copy of" followed by the original title, is owned by you, and starts at version 1 with the note "Duplicated from notebook" and the original's number. Sharing, pinned results and run history are not copied. Duplicate a shared notebook to change it, since shared access is read-only. Works on one notebook at a time.
|
||||
- key: process:sqlWorkbenchRestoreNotebookVersion;slot:help
|
||||
summary: Bring back an earlier saved version of a notebook you own.
|
||||
content: |
|
||||
Select one version under **Versions** and run. The notebook's content goes back to that version and is saved as a new version, with the comment "Restored version" and the number. Other retained versions stay in the history, subject to the retention limit. You must own the notebook to use this action.
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_cell;slot:label
|
||||
summary: 'The notebook''s cells, in order: each is a block of SQL or Markdown notes.'
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_notebook_share;slot:label
|
||||
summary: 'Who this notebook is shared with: users, groups, or everyone.'
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_result_snapshot;slot:label
|
||||
summary: Query results pinned to this notebook, kept as they were when pinned.
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_notebook_version;slot:label
|
||||
summary: Every saved version of this notebook, newest first; use Restore Version to bring one back.
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_execution;slot:label
|
||||
summary: Recent runs of this notebook's queries, with who ran them and how they ended.
|
||||
- key: widget:sqlWorkbenchWidget;slot:label
|
||||
summary: 'The SQL Workbench runner: write, run and save SQL notebooks against your available databases.'
|
||||
- key: widget:sqlWorkbenchHomeSummary;slot:label
|
||||
summary: Counts of notebooks, runs today, runs and failures in the last 7 days, and writes in the last 30 days.
|
||||
- key: widget:sqlWorkbenchHomeRunsPerDay;slot:label
|
||||
summary: A line chart of runs, failures and committed writes for each of the last 30 days.
|
||||
- key: widget:sqlWorkbenchHomeRecentNotebooks;slot:label
|
||||
summary: The 10 notebooks changed most recently that you can open.
|
||||
- key: widget:sqlWorkbenchHomeRecentRuns;slot:label
|
||||
summary: The 10 most recent query runs you can see.
|
||||
- key: widget:sqlWorkbenchHomeSlowestQueries;slot:label
|
||||
summary: The 10 slowest completed query runs of the last 30 days.
|
||||
- key: widget:sqlWorkbenchHomeFailedQueries;slot:label
|
||||
summary: The latest failed, timed-out, rejected or rolled-back runs of the last 30 days, with the error.
|
||||
- key: widget:sqlWorkbenchHomeMostUsedNotebooks;slot:label
|
||||
summary: The notebooks with the most runs in the last 30 days, with the number of users and the last run.
|
||||
- key: widget:sqlWorkbenchHomeSharedNotebooks;slot:label
|
||||
summary: Notebooks that have been shared, who owns them, who they are shared with and since when.
|
||||
- key: widget:sqlWorkbenchHomeWrites;slot:label
|
||||
summary: 'Changes committed to the database through the workbench: who, what, when, and how many rows.'
|
||||
- key: widget:sqlWorkbenchHomeActivityByUser;slot:label
|
||||
summary: Runs, failures and writes per user over the last 30 days; visible to workbench admins.
|
||||
- key: widget:sqlWorkbenchNotebookOpenInRunner;slot:label
|
||||
summary: A button on a notebook that opens it in the runner to edit and run its cells.
|
||||
- key: possibleValueSource:sql_workbench_share_audience_type;slot:help
|
||||
summary: Who a notebook can be shared with.
|
||||
content: |
|
||||
- **User**: one named person.
|
||||
- **Group**: everyone in a named group.
|
||||
- **Everyone (shared library)**: every workbench user.
|
||||
- key: possibleValueSource:sql_workbench_cell_type;slot:help
|
||||
summary: What a notebook cell holds.
|
||||
content: |
|
||||
- **SQL**: a query you can run.
|
||||
- **Markdown**: formatted notes that are not run.
|
||||
- key: possibleValueSource:sql_workbench_backend;slot:help
|
||||
summary: The databases your administrator makes available for workbench queries.
|
||||
- key: possibleValueSource:sql_workbench_notebook;slot:help
|
||||
summary: Choose a notebook you can access by its title.
|
||||
- key: process:sql_workbench_notebook.bulkInsert;slot:help
|
||||
summary: Create notebooks from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook}}**. Review the input before creating records. The same permissions and validation apply as when you add a single record.
|
||||
- key: process:sql_workbench_notebook.bulkEdit;slot:help
|
||||
summary: Change selected notebooks together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_notebook}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit.
|
||||
- key: process:sql_workbench_notebook.bulkEditWithFile;slot:help
|
||||
summary: Update notebooks using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply.
|
||||
- key: process:sql_workbench_notebook.bulkDelete;slot:help
|
||||
summary: Delete selected notebooks.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_notebook}}** before confirming deletion. You can delete only records you have permission to remove. Deleting a notebook also removes its cells, shares, snapshots and versions; execution audit rows remain subject to retention.
|
||||
- key: process:sql_workbench_cell.bulkInsert;slot:help
|
||||
summary: Create cells from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_cell}}**. Review the input before creating records. The same permissions and validation apply as when you add a single record.
|
||||
- key: process:sql_workbench_cell.bulkEdit;slot:help
|
||||
summary: Change selected cells together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_cell}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit.
|
||||
- key: process:sql_workbench_cell.bulkEditWithFile;slot:help
|
||||
summary: Update cells using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_cell}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply.
|
||||
- key: process:sql_workbench_cell.bulkDelete;slot:help
|
||||
summary: Delete selected cells.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_cell}}** before confirming deletion. You can delete only records you have permission to remove.
|
||||
- key: process:sql_workbench_notebook_share.bulkInsert;slot:help
|
||||
summary: Create shares from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook_share}}**. Review the input before creating records. The same permissions and validation apply as when you add a single record.
|
||||
- key: process:sql_workbench_notebook_share.bulkEdit;slot:help
|
||||
summary: Change selected shares together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_notebook_share}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit.
|
||||
- key: process:sql_workbench_notebook_share.bulkEditWithFile;slot:help
|
||||
summary: Update shares using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook_share}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply.
|
||||
- key: process:sql_workbench_notebook_share.bulkDelete;slot:help
|
||||
summary: Delete selected shares.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_notebook_share}}** before confirming deletion. You can delete only records you have permission to remove. Removing a share ends that access grant; other shares can still allow access.
|
||||
- key: process:sql_workbench_result_snapshot.bulkEdit;slot:help
|
||||
summary: Change selected snapshots together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_result_snapshot}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit. Only the snapshot label is editable.
|
||||
- key: process:sql_workbench_result_snapshot.bulkEditWithFile;slot:help
|
||||
summary: Update snapshots using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_result_snapshot}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply. Only the snapshot label is editable.
|
||||
- key: process:sql_workbench_result_snapshot.bulkDelete;slot:help
|
||||
summary: Delete selected snapshots.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_result_snapshot}}** before confirming deletion. You can delete only records you have permission to remove.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -203,15 +203,28 @@ public class BaseSqlWorkbenchTest
|
||||
/*******************************************************************************
|
||||
** Init QContext with a session for one of the test users.
|
||||
*******************************************************************************/
|
||||
protected QSession loginAs(String userId)
|
||||
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).
|
||||
*******************************************************************************/
|
||||
@@ -240,6 +253,14 @@ public class BaseSqlWorkbenchTest
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,20 +270,21 @@ public class BaseSqlWorkbenchTest
|
||||
*******************************************************************************/
|
||||
public static QSession sessionFor(String userId)
|
||||
{
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Name of " + 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") || userId.startsWith("admin")))
|
||||
if(userId != null && (userId.startsWith("reader") || userId.startsWith("writer")))
|
||||
{
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_ACCESS_PERMISSION_BASE_NAME + ".hasAccess");
|
||||
session.getPermissions().addAll(defaults.getUserPermissionNames());
|
||||
}
|
||||
if(userId != null && userId.startsWith("admin"))
|
||||
{
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_ADMIN_PERMISSION_BASE_NAME + ".hasAccess");
|
||||
session.getPermissions().addAll(defaults.getAdminPermissionNames());
|
||||
}
|
||||
if(userId != null && userId.startsWith("writer"))
|
||||
{
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_WRITE_PERMISSION_NAME);
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_SHARE_PERMISSION_NAME);
|
||||
session.getPermissions().addAll(defaults.getSharePermissionNames());
|
||||
}
|
||||
return (session);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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;
|
||||
@@ -41,12 +42,29 @@ 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;
|
||||
@@ -117,7 +135,7 @@ class PostgresIntegrationTest
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("TRUNCATE sql_workbench_result_snapshot, sql_workbench_notebook_version, sql_workbench_cell, sql_workbench_notebook, sql_workbench_execution");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -185,6 +203,106 @@ class PostgresIntegrationTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped DDL gives every foreign-key column and the notebook_id the
|
||||
** "Runs" list joins on a leading index, so notebook pages and notebook
|
||||
** deletes do not scan the child tables.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testShippedDdlIndexesNotebookChildColumns() throws Exception
|
||||
{
|
||||
String sql = """
|
||||
SELECT c.conrelid::regclass::text || '.' || a.attname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND c.conrelid::regclass::text LIKE 'sql_workbench_%'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1])
|
||||
""";
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
java.util.List<String> unindexed = new java.util.ArrayList<>();
|
||||
try(java.sql.ResultSet result = statement.executeQuery(sql))
|
||||
{
|
||||
while(result.next())
|
||||
{
|
||||
unindexed.add(result.getString(1));
|
||||
}
|
||||
}
|
||||
assertThat(unindexed).isEmpty();
|
||||
try(java.sql.ResultSet result = statement.executeQuery("SELECT 1 FROM pg_indexes WHERE indexname = 'ix_sql_workbench_execution_notebook'"))
|
||||
{
|
||||
assertThat(result.next()).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Referential integrity guardrail: every join and every table possible-value
|
||||
** reference between the workbench's tables has a foreign key in the shipped
|
||||
** DDL, or an exemption with its reason. A new reference without a
|
||||
** constraint fails here.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testShippedDdlHasAForeignKeyForEveryReference() throws Exception
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// "child.column" -> why it is deliberately not a foreign key //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Map<String, String> exempt = Map.of(
|
||||
"sql_workbench_execution.notebook_id", "the execution audit is append-only and outlives its notebook: no CASCADE into it, "
|
||||
+ "RESTRICT would block deleting any notebook that ever ran, SET NULL would rewrite audit rows, and an execution records "
|
||||
+ "the client's notebook id even if the notebook was deleted meanwhile (the audit insert must never fail)");
|
||||
|
||||
java.util.Set<String> references = new java.util.TreeSet<>();
|
||||
for(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
for(com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(field.getPossibleValueSourceName() != null && qInstance.getTable(field.getPossibleValueSourceName()) != null)
|
||||
{
|
||||
references.add(table.getName() + "." + field.getBackendName());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData join : qInstance.getJoins().values())
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// the referencing side is the one whose field is not its primary key //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData left = qInstance.getTable(join.getLeftTable());
|
||||
com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData right = qInstance.getTable(join.getRightTable());
|
||||
join.getJoinOns().forEach(on -> references.add(on.getRightField().equals(right.getPrimaryKeyField())
|
||||
? left.getName() + "." + left.getField(on.getLeftField()).getBackendName()
|
||||
: right.getName() + "." + right.getField(on.getRightField()).getBackendName()));
|
||||
}
|
||||
assertThat(references).as("the workbench's references").contains("sql_workbench_cell.notebook_id", "sql_workbench_execution.notebook_id");
|
||||
|
||||
java.util.Set<String> foreignKeys = new java.util.HashSet<>();
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("""
|
||||
SELECT c.conrelid::regclass::text || '.' || a.attname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f'
|
||||
"""))
|
||||
{
|
||||
while(result.next())
|
||||
{
|
||||
foreignKeys.add(result.getString(1));
|
||||
}
|
||||
}
|
||||
|
||||
java.util.List<String> missing = references.stream().filter(r -> !foreignKeys.contains(r) && !exempt.containsKey(r)).toList();
|
||||
assertThat(missing).as("references without a foreign key (add one to docs/ddl/postgres.sql, or an exemption with a reason)").isEmpty();
|
||||
assertThat(foreignKeys).as("exemptions that now have a foreign key").doesNotContainAnyElementsOf(exempt.keySet());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The database itself refuses writes in a workbench read-only session,
|
||||
** even for SQL the classifier would never let through.
|
||||
@@ -231,7 +349,7 @@ class PostgresIntegrationTest
|
||||
statement.execute("DELETE FROM person WHERE first_name = 'after'");
|
||||
}
|
||||
|
||||
List<SqlWorkbenchExecution> audits = QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter());
|
||||
List<SqlWorkbenchExecution> audits = SystemUser.call(() -> QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter()));
|
||||
assertThat(audits).extracting(SqlWorkbenchExecution::getStatus).contains("SUCCEEDED", "TIMED_OUT");
|
||||
}
|
||||
|
||||
@@ -385,4 +503,93 @@ class PostgresIntegrationTest
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.actions.help.HelpTargetCatalog;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Checks the shipped help against the metadata produced by this QBit.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchHelpBundleTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Owned objects and their non-trivial fields must have valid help.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bundleCoversWorkbench()
|
||||
{
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles("help/qbit-sql-workbench.yaml")
|
||||
.owning(target -> target.name().startsWith("sqlWorkbench") || target.name().startsWith("sql_workbench_"))
|
||||
.assertComplete();
|
||||
|
||||
// Core requires apps, tables, fields and processes; also cover our widgets and value sources.
|
||||
var keys = HelpBundles.of(qInstance).getBundles().get(0).getEntries().stream().map(entry -> entry.getKey()).toList();
|
||||
HelpTargetCatalog.collect(qInstance).stream()
|
||||
.filter(target -> target.kind().equals("widget") || target.kind().equals("possibleValueSource"))
|
||||
.filter(target -> target.name().startsWith("sqlWorkbench") || target.name().startsWith("sql_workbench_"))
|
||||
.forEach(target -> assertThat(keys).as(target.key()).contains(target.key()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration remains useful and startup sync is harmless without storage.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registersWithoutHelpContentTable()
|
||||
{
|
||||
assertThat(qInstance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-sql-workbench");
|
||||
assertThat(bundle.getVersion()).isEqualTo(SqlWorkbenchQBitProducer.getVersion());
|
||||
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
|
||||
});
|
||||
assertThatCode(() -> HelpBundles.syncOnStartup(qInstance)).doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
@@ -20,21 +20,34 @@ package com.kingsrook.qbits.sqlworkbench;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.metadata.WorkbenchBackendPossibleValueProvider;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.CellType;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
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.fields.AdornmentType;
|
||||
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.SqlWorkbenchWidgetMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetRenderer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -58,35 +71,73 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
assertThat(q.getArtifactId()).isEqualTo("sql-workbench");
|
||||
});
|
||||
|
||||
for(String name : List.of("sqlWorkbenchNotebook", "sqlWorkbenchCell", "sqlWorkbenchNotebookVersion", "sqlWorkbenchResultSnapshot", "sqlWorkbenchExecution"))
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 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.getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
assertThat(table.getPermissionRules().getPermissionBaseName()).as("tables need the admin permission, not plain access").isEqualTo("sqlWorkbenchAdmin");
|
||||
boolean writable = name.equals("sqlWorkbenchNotebook");
|
||||
assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT)).as(name).isEqualTo(writable);
|
||||
assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE)).as(name).isEqualTo(writable);
|
||||
assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)).as(name).isEqualTo(writable);
|
||||
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.insert", "sql_workbench_execution.edit", "sql_workbench_execution.delete",
|
||||
"sql_workbench_notebook_version.insert", "sql_workbench_notebook_version.edit", "sql_workbench_result_snapshot.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()).containsExactly(SqlWorkbenchWidgetMetaDataProducer.NAME);
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("sqlWorkbench");
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// the qbit produces no processes (only QQQ's generic bulk ones //
|
||||
// are added to its tables by the enricher) //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
assertThat(qInstance.getProcesses() == null ? Map.of() : qInstance.getProcesses()).allSatisfy((name, process) -> assertThat(name).contains("."));
|
||||
assertThat(app.getWidgets()).containsExactlyElementsOf(SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames());
|
||||
assertThat(app.getWidgets()).allSatisfy(name -> assertThat(qInstance.getWidget(name)).isNotNull());
|
||||
assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("sql_workbench");
|
||||
|
||||
assertThat(QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences()).isNotEmpty();
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// 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(MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).getExtensions()).isNotEmpty();
|
||||
assertThat(SqlWorkbenchRuntime.get(qInstance)).isNotNull();
|
||||
assertThat(new SqlWorkbenchWidgetRenderer().render(null).getWidgetData().getType()).isEqualTo("SQL_WORKBENCH");
|
||||
assertThat(SqlWorkbenchQBitProducer.getVersion()).isNotBlank();
|
||||
@@ -106,10 +157,14 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
SqlWorkbenchQBitConfig prefixed = new SqlWorkbenchQBitConfig().withBackendName("other").withRegisterRoutes(false);
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(prefixed).withNamespace("ns").produce(instance);
|
||||
|
||||
assertThat(instance.getTable("ns_sqlWorkbenchNotebook")).isNotNull();
|
||||
assertThat(instance.getTable("ns_sqlWorkbenchCell").getField("notebookId").getPossibleValueSourceName()).isEqualTo("ns_sqlWorkbenchNotebook");
|
||||
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();
|
||||
assertThat(instance.getTable("ns_sql_workbench_cell").getField("backendName").getPossibleValueSourceName()).isEqualTo("ns_sql_workbench_backend");
|
||||
assertThat(instance.getPossibleValueSource("ns_sql_workbench_backend")).isNotNull();
|
||||
assertThat(instance.getTable("ns_sql_workbench_cell").getField("cellType").getPossibleValueSourceName()).isEqualTo("ns_sql_workbench_cell_type");
|
||||
assertThat(instance.getPossibleValueSource("ns_sql_workbench_cell_type")).isNotNull();
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(instance)).isNull();
|
||||
|
||||
SqlWorkbenchRuntime first = prefixed.getRuntime();
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(prefixed).getQBitMetaData();
|
||||
@@ -122,6 +177,129 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner naming rule (1.0.0-RC.4): table, possible-value source, permission
|
||||
** and security key names are lowercase snake_case prefixed with the qbit's
|
||||
** name; the pre-RC.4 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record screens expose readable sections and constrained editor fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordScreenMetaData() throws Exception
|
||||
{
|
||||
QTableMetaData notebook = qInstance.getTable(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
QTableMetaData cell = qInstance.getTable(SqlWorkbenchCell.TABLE_NAME);
|
||||
assertThat(notebook.getSections()).filteredOn(section -> section.getWidgetName() != null)
|
||||
.extracting(section -> section.getLabel())
|
||||
.containsExactly("Runner", "Cells", "Shares", "Snapshots (pinned results)", "Versions", "Runs");
|
||||
for(var section : notebook.getSections())
|
||||
{
|
||||
if(section.getWidgetName() != null && !"runner".equals(section.getName()))
|
||||
{
|
||||
assertThat(qInstance.getWidget(section.getWidgetName()).getLabel()).isEqualTo(section.getLabel());
|
||||
}
|
||||
}
|
||||
String backendPvs = WorkbenchBackendPossibleValueProvider.NAME;
|
||||
assertThat(notebook.getField("defaultBackendName").getPossibleValueSourceName()).isEqualTo(backendPvs);
|
||||
assertThat(cell.getField("backendName").getPossibleValueSourceName()).isEqualTo(backendPvs);
|
||||
assertThat(qInstance.getPossibleValueSource(backendPvs).getType().name()).isEqualTo("CUSTOM");
|
||||
assertThat(cell.getField("cellKey").getIsEditable()).isFalse();
|
||||
assertThat(qInstance.getTable(SqlWorkbenchResultSnapshot.TABLE_NAME).getField("label").getIsEditable()).isTrue();
|
||||
assertThat(qInstance.getTable(SqlWorkbenchResultSnapshot.TABLE_NAME).getField("resultJson").getIsEditable()).isFalse();
|
||||
assertThat(cell.getField("cellType").getDefaultValue()).isEqualTo("SQL");
|
||||
assertThat(cell.getField("cellType").getPossibleValueSourceName()).isEqualTo(CellType.NAME);
|
||||
assertThat(qInstance.getPossibleValueSource(CellType.NAME).getEnumValues())
|
||||
.extracting(value -> value.getId().toString()).containsExactly("SQL", "MARKDOWN");
|
||||
for(String table : List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME))
|
||||
{
|
||||
var field = qInstance.getTable(table).getField("parametersJson");
|
||||
assertThat(field.getAdornment(AdornmentType.CODE_EDITOR).orElseThrow().getValues())
|
||||
.containsEntry("languageMode", "json");
|
||||
assertThat(field.getIsEditable()).isEqualTo(table.equals(SqlWorkbenchNotebook.TABLE_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The live backend PVS honors all execution target exclusions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBackendChoices() throws Exception
|
||||
{
|
||||
qInstance.addBackend(defineH2Backend("other"));
|
||||
qInstance.addBackend(defineH2Backend("excluded"));
|
||||
qInstance.addBackend(defineH2Backend("h2__sqlWorkbenchReadOnly"));
|
||||
qInstance.addBackend(defineH2Backend("h2__sqlWorkbenchWrite"));
|
||||
qInstance.addBackend(new QBackendMetaData().withName("mem").withBackendType(MemoryBackendModule.class));
|
||||
config.setExcludedBackendNames(List.of("excluded"));
|
||||
var provider = new WorkbenchBackendPossibleValueProvider();
|
||||
var input = new SearchPossibleValueSourceInput();
|
||||
assertThat(provider.search(input)).extracting(value -> value.getId()).containsExactlyInAnyOrder(BACKEND_NAME, "other");
|
||||
assertThat(provider.getPossibleValue(BACKEND_NAME).getLabel()).isEqualTo(BACKEND_NAME);
|
||||
assertThat(provider.getPossibleValue("excluded")).isNull();
|
||||
assertThat(provider.getPossibleValue("unknown")).isNull();
|
||||
config.setAllowedBackendNames(List.of(BACKEND_NAME, "excluded"));
|
||||
assertThat(provider.search(input)).extracting(value -> value.getId()).containsExactly(BACKEND_NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -156,7 +334,7 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
.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("sqlWorkbench"), instance))
|
||||
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"));
|
||||
@@ -180,8 +358,8 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
.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("sqlWorkbench.hasAccess");
|
||||
assertThat(config.getAdminPermissionName()).isEqualTo("sqlWorkbenchAdmin.hasAccess");
|
||||
assertThat(config.getAccessPermissionName()).isEqualTo("sql_workbench.hasAccess");
|
||||
assertThat(config.getAdminPermissionName()).isEqualTo("sql_workbench_admin.hasAccess");
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// credentials never serialize - jdbcUrl can carry a password //
|
||||
@@ -189,7 +367,7 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
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("sqlWorkbenchAdmin");
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHelp;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every sql_workbench_* table:
|
||||
** - runtime code (not just its model, metadata or home dashboard) reads or
|
||||
** writes it, so no table exists only on paper;
|
||||
** - it is listed in STARTS_EMPTY with why (every table here is user content or
|
||||
** a log, so none is seeded);
|
||||
** - it has bundle help that says what it is for and how its rows get created.
|
||||
** A new table fails this test until it is wired and explained.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchTablesGuardrailTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "sqlworkbench");
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table legitimately starts empty, and where its rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.of(
|
||||
"sql_workbench_notebook", "user content: someone saves a notebook",
|
||||
"sql_workbench_cell", "user content: the cells of a saved notebook",
|
||||
"sql_workbench_notebook_version", "runtime: every notebook save",
|
||||
"sql_workbench_notebook_share", "user content: someone shares a notebook",
|
||||
"sql_workbench_result_snapshot", "user content: someone pins a result",
|
||||
"sql_workbench_execution", "runtime: every statement run (the audit)");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> entityByTable = entityByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("home")))
|
||||
.map(SqlWorkbenchTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : workbenchTables())
|
||||
{
|
||||
String entity = entityByTable.get(table);
|
||||
assertThat(entity).as("entity class for " + table).isNotNull();
|
||||
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is explained as starting empty.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsExplained()
|
||||
{
|
||||
assertThat(workbenchTables()).isEqualTo(STARTS_EMPTY.keySet().stream().sorted().toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(SqlWorkbenchHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
for(String table : workbenchTables())
|
||||
{
|
||||
QTableMetaData meta = qInstance.getTable(table);
|
||||
List<QHelpContent> help = meta.getHelpContent() == null ? null : meta.getHelpContent().get(SqlWorkbenchHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(qInstance.getApp("sqlWorkbench").getHelpContent()).containsKey(SqlWorkbenchHelp.SLOT);
|
||||
assertThat(SqlWorkbenchHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Help a host already wrote is kept; the bundle only fills empty slots.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsNotOverridden() throws Exception
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable("sql_workbench_notebook");
|
||||
table.getHelpContent().remove(SqlWorkbenchHelp.SLOT);
|
||||
table.withHelpContent(SqlWorkbenchHelp.SLOT, new QHelpContent("host words"));
|
||||
|
||||
SqlWorkbenchHelp.apply(qInstance, config);
|
||||
|
||||
assertThat(table.getHelpContent().get(SqlWorkbenchHelp.SLOT).get(0).getContent()).isEqualTo("host words");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> workbenchTables()
|
||||
{
|
||||
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("sql_workbench_")).sorted().toList();
|
||||
assertThat(tables).hasSize(6);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** model/X.java declares TABLE_NAME = "x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> entityByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
|
||||
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
|
||||
{
|
||||
for(Path file : files.toList())
|
||||
{
|
||||
Matcher m = p.matcher(read(file));
|
||||
if(m.find())
|
||||
{
|
||||
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The committed fragment describes exactly the shipped extension operations.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchApiContractTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Regenerate with -DsqlWorkbench.openapi.update=true after intentional changes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
var extension = SqlWorkbenchEndpoints.extension();
|
||||
var version = new MiddlewareVersionV1();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
Path path = Path.of("src/main/resources/openapi/qqq-v1/sql-workbench.yaml");
|
||||
if(Boolean.getBoolean("sqlWorkbench.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, generated);
|
||||
}
|
||||
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
|
||||
"./mvnw -Dtest=SqlWorkbenchApiContractTest -DsqlWorkbench.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("SqlWorkbench"));
|
||||
assertThat(model.getPaths().keySet()).allMatch(name -> name.startsWith("/qqq/v1/sql-workbench/"));
|
||||
assertThat(extension.getSpecs()).hasSize(21);
|
||||
assertThat(extension.getEffectivePathPrefix()).isEqualTo("/sql-workbench");
|
||||
Set<String> operationIds = new HashSet<>();
|
||||
Set<String> componentNames = new HashSet<>();
|
||||
for(var reference : extension.getSpecs())
|
||||
{
|
||||
var spec = (com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec<?, ?, ?>) Class.forName(reference.getName()).getConstructor().newInstance();
|
||||
assertThat(spec.isSecured()).isTrue();
|
||||
assertThat(spec.defineMethod().getOperationId()).startsWith("sqlWorkbench");
|
||||
assertThat(operationIds.add(spec.defineMethod().getOperationId())).isTrue();
|
||||
for(String name : spec.defineComponentSchemas().keySet())
|
||||
{
|
||||
assertThat(componentNames.add(name)).as("Component declared once: " + name).isTrue();
|
||||
}
|
||||
}
|
||||
assertThat(generated).contains("text/csv; charset=utf-8", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "format: \"binary\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* 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.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The routes over real HTTP (Javalin on a random port, MOCK authentication
|
||||
** with the sessionId cookie choosing the test user).
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchEndpointSpecTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private QApplicationJavalinServer javalin;
|
||||
private String base;
|
||||
private HttpClient client = HttpClient.newHttpClient();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void startServer() throws Exception
|
||||
{
|
||||
QContext.clear();
|
||||
javalin = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Serve the same instance and runtime prepared by the test fixture.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (qInstance);
|
||||
}
|
||||
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true);
|
||||
javalin.start();
|
||||
base = "http://localhost:" + javalin.getBoundPort() + "/qqq/v1/sql-workbench";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stopServer()
|
||||
{
|
||||
javalin.stop();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> send(String method, String path, String user, String body, String contentType) throws Exception
|
||||
{
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + path)).header("Cookie", "sessionId=" + user);
|
||||
if(contentType != null)
|
||||
{
|
||||
builder.header("Content-Type", contentType);
|
||||
}
|
||||
builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
|
||||
return (client.send(builder.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> json(HttpResponse<String> response) throws Exception
|
||||
{
|
||||
return (JsonUtils.toObject(response.body(), new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRegistration()
|
||||
{
|
||||
SqlWorkbenchEndpoints.register(qInstance);
|
||||
var extensions = MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).getExtensions();
|
||||
assertThat(extensions).containsOnlyKeys("sql-workbench");
|
||||
assertThat(extensions.get("sql-workbench").getSpecs()).hasSize(21);
|
||||
assertThat(javalin.isStrictUiApiRoutes()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testEndToEnd() throws Exception
|
||||
{
|
||||
assertThat(send("GET", "/capabilities", "nobody", null, null).statusCode()).isEqualTo(403);
|
||||
assertThat(send("GET", "/capabilities", READER, null, null).statusCode()).isEqualTo(200);
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// JSON only - a form post (CSRF shape) is refused //
|
||||
////////////////////////////////////////////////////////////
|
||||
assertThat(send("POST", "/execute", READER, "backendName=h2&sql=SELECT+1", "application/x-www-form-urlencoded").statusCode()).isEqualTo(400);
|
||||
assertThat(send("POST", "/execute", READER, "not json", "application/json").statusCode()).isEqualTo(400);
|
||||
|
||||
HttpResponse<String> run = send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT id, salary FROM person WHERE salary > :s ORDER BY id\",\"params\":{\"s\":25000.25}}", "application/json");
|
||||
assertThat(run.statusCode()).isEqualTo(200);
|
||||
Map<String, Object> result = json(run);
|
||||
assertThat(result.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(result.get("rowCount")).isEqualTo(2);
|
||||
String uuid = (String) result.get("executionUuid");
|
||||
|
||||
assertThat(json(send("GET", "/executions/" + uuid + "/rows?offset=1&limit=1&sort=0&dir=desc", READER, null, null)).get("rows")).asList().hasSize(1);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/rows?offset=x", READER, null, null).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/rows", OTHER_READER, null, null).statusCode()).isEqualTo(404);
|
||||
|
||||
HttpResponse<String> csv = send("GET", "/executions/" + uuid + "/export?format=csv", READER, null, null);
|
||||
assertThat(csv.statusCode()).isEqualTo(200);
|
||||
assertThat(csv.headers().firstValue("Content-Disposition").orElse("")).contains("attachment").contains(".csv");
|
||||
assertThat(csv.body()).contains("id,salary");
|
||||
assertThat(send("GET", "/executions/" + uuid + "/export?format=pdf", READER, null, null).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/export", OTHER_READER, null, null).statusCode()).isEqualTo(404);
|
||||
|
||||
assertThat(send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"DELETE FROM person\",\"mode\":\"WRITE\"}", "application/json").statusCode()).isEqualTo(403);
|
||||
assertThat(send("POST", "/execute", READER, "{\"backendName\":\"nope\",\"sql\":\"SELECT 1\"}", "application/json").statusCode()).isEqualTo(404);
|
||||
assertThat(json(send("POST", "/explain", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1\"}", "application/json")).get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(json(send("POST", "/executions/" + uuid + "/cancel", READER, "{}", "application/json")).get("cancelled")).isEqualTo(false);
|
||||
assertThat((List<?>) json(send("GET", "/history?limit=5", READER, null, null)).get("executions")).hasSize(3);
|
||||
assertThat(json(send("GET", "/schema/h2", READER, null, null)).get("dialect")).isEqualTo("h2");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a client-chosen id: any case on the paths, single-use (409) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
String clientUuid = "3F2504E0-4F89-11D3-9A0C-0305E82C3301";
|
||||
String body = "{\"backendName\":\"h2\",\"sql\":\"SELECT 1 AS one\",\"executionUuid\":\"" + clientUuid + "\"}";
|
||||
assertThat(json(send("POST", "/execute", READER, body, "application/json")).get("executionUuid")).isEqualTo(clientUuid.toLowerCase());
|
||||
assertThat(send("GET", "/executions/" + clientUuid + "/rows", READER, null, null).statusCode()).isEqualTo(200);
|
||||
assertThat(send("GET", "/executions/" + clientUuid + "/export", READER, null, null).statusCode()).isEqualTo(200);
|
||||
HttpResponse<String> again = send("POST", "/execute", READER, body, "application/json");
|
||||
assertThat(again.statusCode()).isEqualTo(409);
|
||||
assertThat(again.body()).contains("already used");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookRoutes() throws Exception
|
||||
{
|
||||
Map<String, Object> created = json(send("POST", "/notebooks", WRITER, "{\"title\":\"N1\",\"cells\":[{\"cellKey\":\"a\",\"cellType\":\"SQL\",\"content\":\"SELECT 1\"}]}", "application/json"));
|
||||
Integer id = (Integer) created.get("id");
|
||||
|
||||
assertThat((List<?>) json(send("GET", "/notebooks?scope=mine", WRITER, null, null)).get("notebooks")).hasSize(1);
|
||||
assertThat(json(send("GET", "/notebooks/" + id, WRITER, null, null)).get("title")).isEqualTo("N1");
|
||||
assertThat(send("GET", "/notebooks/" + id, READER, null, null).statusCode()).isEqualTo(404);
|
||||
assertThat(send("GET", "/notebooks/abc", WRITER, null, null).statusCode()).isEqualTo(400);
|
||||
|
||||
assertThat(json(send("PUT", "/notebooks/" + id, WRITER, "{\"title\":\"N2\"}", "application/json")).get("currentVersion")).isEqualTo(2);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/publish", WRITER, "{\"shared\":true}", "application/json")).get("visibility")).isEqualTo("SHARED");
|
||||
assertThat((List<?>) json(send("GET", "/notebooks?scope=shared", READER, null, null)).get("notebooks")).hasSize(1);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/duplicate", READER, "{}", "application/json")).get("ownerUserId")).isEqualTo(READER);
|
||||
assertThat((List<?>) json(send("GET", "/notebooks/" + id + "/versions", WRITER, null, null)).get("versions")).hasSize(2);
|
||||
assertThat(json(send("GET", "/notebooks/" + id + "/versions/1", WRITER, null, null)).get("versionNumber")).isEqualTo(1);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/versions/1/restore", WRITER, "{}", "application/json")).get("title")).isEqualTo("N1");
|
||||
|
||||
String uuid = (String) json(send("POST", "/execute", WRITER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1 AS one\"}", "application/json")).get("executionUuid");
|
||||
Map<String, Object> snapshot = json(send("POST", "/notebooks/" + id + "/snapshots", WRITER, "{\"executionUuid\":\"" + uuid + "\",\"cellKey\":\"a\"}", "application/json"));
|
||||
assertThat((List<?>) json(send("GET", "/notebooks/" + id + "/snapshots", WRITER, null, null)).get("snapshots")).hasSize(1);
|
||||
assertThat(json(send("DELETE", "/notebooks/" + id + "/snapshots/" + snapshot.get("id"), WRITER, null, null)).get("deleted")).isEqualTo(true);
|
||||
|
||||
assertThat(send("DELETE", "/notebooks/" + id, READER, null, null).statusCode()).isEqualTo(403);
|
||||
assertThat(json(send("DELETE", "/notebooks/" + id, WRITER, null, null)).get("deleted")).isEqualTo(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every spec checks access before parsing bodies, identifiers or formats.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryOperationDeniesMissingAccess() throws Exception
|
||||
{
|
||||
for(var reference : SqlWorkbenchEndpoints.extension().getSpecs())
|
||||
{
|
||||
var spec = (AbstractEndpointSpec<?, ?, ?>) Class.forName(reference.getName()).getConstructor().newInstance();
|
||||
var operation = spec.defineBasicOperation();
|
||||
String path = operation.getPath().substring("/sql-workbench".length()).replaceAll("\\{[^}]+}", "invalid");
|
||||
String method = operation.getHttpMethod().name();
|
||||
boolean body = method.equals("POST") || method.equals("PUT");
|
||||
HttpResponse<String> response = send(method, path + "?format=invalid", "nobody", body ? "not json" : null, body ? "text/plain" : null);
|
||||
assertThat(response.statusCode()).as(method + " " + path).isEqualTo(403);
|
||||
assertThat(json(response)).containsEntry("error", "Permission denied.");
|
||||
HttpRequest unauthenticated = HttpRequest.newBuilder(URI.create(base + path))
|
||||
.header("Authorization", "Bearer Deny")
|
||||
.method(method, HttpRequest.BodyPublishers.noBody()).build();
|
||||
assertThat(client.send(unauthenticated, HttpResponse.BodyHandlers.ofString()).statusCode()).as(method + " authentication").isEqualTo(401);
|
||||
}
|
||||
assertThat(json(send("GET", "/history", READER, null, null)).get("executions")).asList().isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write gates, read-only classification and concurrency limits remain audited.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExecutionGatesAndLimits() throws Exception
|
||||
{
|
||||
String write = "{\"backendName\":\"h2\",\"sql\":\"DELETE FROM person\",\"mode\":\"WRITE\"}";
|
||||
assertThat(send("POST", "/execute", READER, write, "application/json").statusCode()).isEqualTo(403);
|
||||
config.setWriteModeEnabled(false);
|
||||
assertThat(send("POST", "/execute", WRITER, write, "application/json").statusCode()).isEqualTo(403);
|
||||
String analyze = "{\"backendName\":\"h2\",\"sql\":\"SELECT 1\",\"analyze\":true}";
|
||||
assertThat(send("POST", "/explain", WRITER, analyze, "application/json").statusCode()).isEqualTo(403);
|
||||
config.setWriteModeEnabled(true);
|
||||
assertThat(send("POST", "/explain", READER, analyze, "application/json").statusCode()).isEqualTo(403);
|
||||
assertThat(send("POST", "/execute", WRITER, write.replace(",\"mode\":\"WRITE\"", ""), "application/json").statusCode()).isEqualTo(400);
|
||||
|
||||
config.setMaxConcurrentExecutionsPerUser(1);
|
||||
var running = SqlWorkbenchRuntime.get(qInstance).getRunningStatements();
|
||||
String uuid = "3f2504e0-4f89-11d3-9a0c-0305e82c3302";
|
||||
var registration = running.register(uuid, READER, 1);
|
||||
try
|
||||
{
|
||||
HttpResponse<String> limited = send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1\"}", "application/json");
|
||||
assertThat(limited.statusCode()).isEqualTo(400);
|
||||
assertThat(limited.body()).contains("executions running");
|
||||
assertThat(json(send("POST", "/executions/" + uuid + "/cancel", OTHER_READER, "{}", "application/json"))).containsEntry("cancelled", false);
|
||||
assertThat(registration.isCancelled()).isFalse();
|
||||
assertThat(json(send("POST", "/executions/" + uuid + "/cancel", READER, "{}", "application/json"))).containsEntry("cancelled", true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
running.unregister(uuid, registration);
|
||||
}
|
||||
List<?> history = (List<?>) json(send("GET", "/history", READER, null, null)).get("executions");
|
||||
assertThat(history).hasSize(3).allSatisfy(entry -> assertThat(((Map<?, ?>) entry).get("status")).isEqualTo("REJECTED"));
|
||||
Map<String, Object> count = json(send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT COUNT(*) AS n FROM person\"}", "application/json"));
|
||||
assertThat(count.get("rows")).isEqualTo(List.of(List.of(5)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share permission, ownership and cached-result ownership apply to mutations.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookMutationDenials() throws Exception
|
||||
{
|
||||
Integer id = (Integer) json(send("POST", "/notebooks", READER, "{\"title\":\"Private\"}", "application/json")).get("id");
|
||||
assertThat(send("POST", "/notebooks/" + id + "/publish", READER, "{\"shared\":true}", "application/json").statusCode()).isEqualTo(403);
|
||||
for(String suffix : List.of("", "/versions", "/versions/1", "/snapshots"))
|
||||
{
|
||||
assertThat(send("GET", "/notebooks/" + id + suffix, OTHER_READER, null, null).statusCode()).isEqualTo(404);
|
||||
}
|
||||
assertThat(send("PUT", "/notebooks/" + id, OTHER_READER, "{\"title\":\"Stolen\"}", "application/json").statusCode()).isEqualTo(404);
|
||||
assertThat(send("POST", "/notebooks/" + id + "/duplicate", OTHER_READER, "{}", "application/json").statusCode()).isEqualTo(404);
|
||||
assertThat(send("POST", "/notebooks/" + id + "/versions/1/restore", OTHER_READER, "{}", "application/json").statusCode()).isEqualTo(404);
|
||||
|
||||
Integer shared = (Integer) json(send("POST", "/notebooks", WRITER, "{\"title\":\"Shared\"}", "application/json")).get("id");
|
||||
send("POST", "/notebooks/" + shared + "/publish", WRITER, "{\"shared\":true}", "application/json");
|
||||
assertThat(send("PUT", "/notebooks/" + shared, READER, "{\"title\":\"Stolen\"}", "application/json").statusCode()).isEqualTo(403);
|
||||
assertThat(send("POST", "/notebooks/" + shared + "/versions/1/restore", READER, "{}", "application/json").statusCode()).isEqualTo(403);
|
||||
String uuid = (String) json(send("POST", "/execute", WRITER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1\"}", "application/json")).get("executionUuid");
|
||||
String snapshotBody = "{\"executionUuid\":\"" + uuid + "\",\"cellKey\":\"a\"}";
|
||||
assertThat(send("POST", "/notebooks/" + id + "/snapshots", READER, snapshotBody, "application/json").statusCode()).isEqualTo(404);
|
||||
Integer snapshotId = (Integer) json(send("POST", "/notebooks/" + shared + "/snapshots", WRITER, snapshotBody, "application/json")).get("id");
|
||||
assertThat(send("DELETE", "/notebooks/" + shared + "/snapshots/" + snapshotId, READER, null, null).statusCode()).isEqualTo(403);
|
||||
assertThat(json(send("GET", "/notebooks/" + id, READER, null, null))).containsEntry("title", "Private");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep decimal parameters, null bodies, SQL failures and all export formats.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWireCompatibility() throws Exception
|
||||
{
|
||||
HttpResponse<String> run = send("POST", "/execute", READER,
|
||||
"{\"backendName\":\"h2\",\"sql\":\"SELECT CAST(:n AS DECIMAL(30,20)) AS n\",\"params\":{\"n\":0.12345678901234567890}}", "application/json");
|
||||
assertThat(run.statusCode()).isEqualTo(200);
|
||||
assertThat(run.body()).contains("0.12345678901234567890");
|
||||
String uuid = (String) json(run).get("executionUuid");
|
||||
for(String format : List.of("csv", "json", "xlsx"))
|
||||
{
|
||||
HttpResponse<String> export = send("GET", "/executions/" + uuid + "/export?format=" + format, READER, null, null);
|
||||
assertThat(export.statusCode()).isEqualTo(200);
|
||||
assertThat(export.headers().firstValue("Cache-Control")).contains("no-store");
|
||||
assertThat(export.headers().firstValue("Content-Disposition").orElseThrow()).endsWith("." + format + "\"");
|
||||
if(format.equals("json"))
|
||||
{
|
||||
assertThat(export.body()).contains("0.12345678901234567890");
|
||||
}
|
||||
}
|
||||
assertThat(json(send("POST", "/executions/" + uuid + "/cancel", READER, "null", "application/json"))).containsEntry("cancelled", false);
|
||||
assertThat(send("POST", "/execute", READER, "[]", "application/json").statusCode()).isEqualTo(400);
|
||||
assertThat(send("POST", "/execute", READER, "{}", null).statusCode()).isEqualTo(400);
|
||||
HttpResponse<String> failed = send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT * FROM nonexistent_table\"}", "application/json");
|
||||
assertThat(failed.statusCode()).isEqualTo(200);
|
||||
assertThat(json(failed)).containsEntry("status", "FAILED");
|
||||
String served = client.send(HttpRequest.newBuilder(URI.create(base.replace("/sql-workbench", "/openapi.yaml"))).build(), HttpResponse.BodyHandlers.ofString()).body();
|
||||
assertThat(served).contains("sqlWorkbenchExecute", "sqlWorkbenchExportResult", "SqlWorkbenchNotebookV1");
|
||||
}
|
||||
|
||||
}
|
||||
-203
@@ -1,203 +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.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The routes over real HTTP (Javalin on a random port, MOCK authentication
|
||||
** with the sessionId cookie choosing the test user).
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchRouteProviderTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private Javalin javalin;
|
||||
private String base;
|
||||
private HttpClient client = HttpClient.newHttpClient();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void startServer()
|
||||
{
|
||||
QContext.clear();
|
||||
SqlWorkbenchRouteProvider provider = new SqlWorkbenchRouteProvider();
|
||||
provider.setQInstance(qInstance);
|
||||
javalin = Javalin.create(provider::acceptJavalinConfig).start(0);
|
||||
base = "http://localhost:" + javalin.port() + SqlWorkbenchRouteProvider.BASE_PATH;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stopServer()
|
||||
{
|
||||
javalin.stop();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> send(String method, String path, String user, String body, String contentType) throws Exception
|
||||
{
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + path)).header("Cookie", "sessionId=" + user);
|
||||
if(contentType != null)
|
||||
{
|
||||
builder.header("Content-Type", contentType);
|
||||
}
|
||||
builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
|
||||
return (client.send(builder.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> json(HttpResponse<String> response) throws Exception
|
||||
{
|
||||
return (JsonUtils.toObject(response.body(), new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRegistration()
|
||||
{
|
||||
com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchRoutes.register(qInstance);
|
||||
List<QCodeReference> references = QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences();
|
||||
assertThat(references).filteredOn(r -> r.getName().equals(SqlWorkbenchRouteProvider.class.getName())).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testEndToEnd() throws Exception
|
||||
{
|
||||
assertThat(send("GET", "/capabilities", "nobody", null, null).statusCode()).isEqualTo(403);
|
||||
assertThat(send("GET", "/capabilities", READER, null, null).statusCode()).isEqualTo(200);
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// JSON only - a form post (CSRF shape) is refused //
|
||||
////////////////////////////////////////////////////////////
|
||||
assertThat(send("POST", "/execute", READER, "backendName=h2&sql=SELECT+1", "application/x-www-form-urlencoded").statusCode()).isEqualTo(400);
|
||||
assertThat(send("POST", "/execute", READER, "not json", "application/json").statusCode()).isEqualTo(400);
|
||||
|
||||
HttpResponse<String> run = send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT id, salary FROM person WHERE salary > :s ORDER BY id\",\"params\":{\"s\":25000.25}}", "application/json");
|
||||
assertThat(run.statusCode()).isEqualTo(200);
|
||||
Map<String, Object> result = json(run);
|
||||
assertThat(result.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(result.get("rowCount")).isEqualTo(2);
|
||||
String uuid = (String) result.get("executionUuid");
|
||||
|
||||
assertThat(json(send("GET", "/executions/" + uuid + "/rows?offset=1&limit=1&sort=0&dir=desc", READER, null, null)).get("rows")).asList().hasSize(1);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/rows?offset=x", READER, null, null).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/rows", OTHER_READER, null, null).statusCode()).isEqualTo(404);
|
||||
|
||||
HttpResponse<String> csv = send("GET", "/executions/" + uuid + "/export?format=csv", READER, null, null);
|
||||
assertThat(csv.statusCode()).isEqualTo(200);
|
||||
assertThat(csv.headers().firstValue("Content-Disposition").orElse("")).contains("attachment").contains(".csv");
|
||||
assertThat(csv.body()).contains("id,salary");
|
||||
assertThat(send("GET", "/executions/" + uuid + "/export?format=pdf", READER, null, null).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/export", OTHER_READER, null, null).statusCode()).isEqualTo(404);
|
||||
|
||||
assertThat(send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"DELETE FROM person\",\"mode\":\"WRITE\"}", "application/json").statusCode()).isEqualTo(403);
|
||||
assertThat(send("POST", "/execute", READER, "{\"backendName\":\"nope\",\"sql\":\"SELECT 1\"}", "application/json").statusCode()).isEqualTo(404);
|
||||
assertThat(json(send("POST", "/explain", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1\"}", "application/json")).get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(json(send("POST", "/executions/" + uuid + "/cancel", READER, "{}", "application/json")).get("cancelled")).isEqualTo(false);
|
||||
assertThat((List<?>) json(send("GET", "/history?limit=5", READER, null, null)).get("executions")).hasSize(3);
|
||||
assertThat(json(send("GET", "/schema/h2", READER, null, null)).get("dialect")).isEqualTo("h2");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a client-chosen id: any case on the paths, single-use (409) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
String clientUuid = "3F2504E0-4F89-11D3-9A0C-0305E82C3301";
|
||||
String body = "{\"backendName\":\"h2\",\"sql\":\"SELECT 1 AS one\",\"executionUuid\":\"" + clientUuid + "\"}";
|
||||
assertThat(json(send("POST", "/execute", READER, body, "application/json")).get("executionUuid")).isEqualTo(clientUuid.toLowerCase());
|
||||
assertThat(send("GET", "/executions/" + clientUuid + "/rows", READER, null, null).statusCode()).isEqualTo(200);
|
||||
assertThat(send("GET", "/executions/" + clientUuid + "/export", READER, null, null).statusCode()).isEqualTo(200);
|
||||
HttpResponse<String> again = send("POST", "/execute", READER, body, "application/json");
|
||||
assertThat(again.statusCode()).isEqualTo(409);
|
||||
assertThat(again.body()).contains("already used");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookRoutes() throws Exception
|
||||
{
|
||||
Map<String, Object> created = json(send("POST", "/notebooks", WRITER, "{\"title\":\"N1\",\"cells\":[{\"cellKey\":\"a\",\"cellType\":\"SQL\",\"content\":\"SELECT 1\"}]}", "application/json"));
|
||||
Integer id = (Integer) created.get("id");
|
||||
|
||||
assertThat((List<?>) json(send("GET", "/notebooks?scope=mine", WRITER, null, null)).get("notebooks")).hasSize(1);
|
||||
assertThat(json(send("GET", "/notebooks/" + id, WRITER, null, null)).get("title")).isEqualTo("N1");
|
||||
assertThat(send("GET", "/notebooks/" + id, READER, null, null).statusCode()).isEqualTo(404);
|
||||
assertThat(send("GET", "/notebooks/abc", WRITER, null, null).statusCode()).isEqualTo(400);
|
||||
|
||||
assertThat(json(send("PUT", "/notebooks/" + id, WRITER, "{\"title\":\"N2\"}", "application/json")).get("currentVersion")).isEqualTo(2);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/publish", WRITER, "{\"shared\":true}", "application/json")).get("visibility")).isEqualTo("SHARED");
|
||||
assertThat((List<?>) json(send("GET", "/notebooks?scope=shared", READER, null, null)).get("notebooks")).hasSize(1);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/duplicate", READER, "{}", "application/json")).get("ownerUserId")).isEqualTo(READER);
|
||||
assertThat((List<?>) json(send("GET", "/notebooks/" + id + "/versions", WRITER, null, null)).get("versions")).hasSize(2);
|
||||
assertThat(json(send("GET", "/notebooks/" + id + "/versions/1", WRITER, null, null)).get("versionNumber")).isEqualTo(1);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/versions/1/restore", WRITER, "{}", "application/json")).get("title")).isEqualTo("N1");
|
||||
|
||||
String uuid = (String) json(send("POST", "/execute", WRITER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1 AS one\"}", "application/json")).get("executionUuid");
|
||||
Map<String, Object> snapshot = json(send("POST", "/notebooks/" + id + "/snapshots", WRITER, "{\"executionUuid\":\"" + uuid + "\",\"cellKey\":\"a\"}", "application/json"));
|
||||
assertThat((List<?>) json(send("GET", "/notebooks/" + id + "/snapshots", WRITER, null, null)).get("snapshots")).hasSize(1);
|
||||
assertThat(json(send("DELETE", "/notebooks/" + id + "/snapshots/" + snapshot.get("id"), WRITER, null, null)).get("deleted")).isEqualTo(true);
|
||||
|
||||
assertThat(send("DELETE", "/notebooks/" + id, READER, null, null).statusCode()).isEqualTo(403);
|
||||
assertThat(json(send("DELETE", "/notebooks/" + id, WRITER, null, null)).get("deleted")).isEqualTo(true);
|
||||
}
|
||||
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* 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.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.developer.DeveloperPanelsAction;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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.DeveloperPanelBlockType;
|
||||
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.DeveloperPanelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
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.BaseSqlWorkbenchTest;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit test for {@link SqlWorkbenchDeveloperPanelProvider} and
|
||||
** {@link RecordSql}.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchDeveloperPanelProviderTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private static final String TABLE_NAME = "orderLine";
|
||||
private static final String SKU = "AB'C-1";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table whose backend names differ from its QQQ names, with a string
|
||||
** primary key, plus a table on a non-RDBMS backend.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
qInstance.addTable(new QTableMetaData()
|
||||
.withName(TABLE_NAME)
|
||||
.withBackendName(BACKEND_NAME)
|
||||
.withPrimaryKeyField("sku")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("order_line"))
|
||||
.withField(new QFieldMetaData("sku", QFieldType.STRING).withBackendName("line_sku"))
|
||||
.withField(new QFieldMetaData("quantity", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("unitPrice", QFieldType.DECIMAL).withBackendName("unit_price")));
|
||||
|
||||
qInstance.addBackend(new QBackendMetaData().withName("memory"));
|
||||
qInstance.addTable(new QTableMetaData()
|
||||
.withName("memoryThing")
|
||||
.withBackendName("memory")
|
||||
.withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
|
||||
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP TABLE IF EXISTS order_line");
|
||||
statement.execute("CREATE TABLE order_line (line_sku VARCHAR(40) PRIMARY KEY, quantity INT, unit_price DECIMAL(10,2))");
|
||||
statement.execute("INSERT INTO order_line VALUES ('AB''C-1', 3, 9.50), ('other', 1, 1.00)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private DeveloperPanelContext context(String tableName, Object primaryKey)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
return (new DeveloperPanelContext().withTable(table).withRecord(new QRecord().withValue(table.getPrimaryKeyField(), (java.io.Serializable) primaryKey)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The producer registers the provider; the panel shows through the
|
||||
** framework action with the record and table queries, backend details,
|
||||
** links and the note.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPanel() throws Exception
|
||||
{
|
||||
assertThat(DeveloperPanelsMetaData.of(qInstance).getProviders())
|
||||
.anyMatch(reference -> SqlWorkbenchDeveloperPanelProvider.class.getName().equals(reference.getName()));
|
||||
|
||||
DeveloperPanel panel = new DeveloperPanelsAction().execute(context(TABLE_NAME, SKU)).stream()
|
||||
.filter(p -> SqlWorkbenchDeveloperPanelProvider.ID.equals(p.getId())).findFirst().orElseThrow();
|
||||
assertNull(panel.getError());
|
||||
assertEquals("SQL Workbench", panel.getTitle());
|
||||
assertEquals(450, panel.getOrder());
|
||||
assertEquals("terminal", panel.getIcon());
|
||||
|
||||
List<DeveloperPanelBlock> code = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.CODE).toList();
|
||||
assertEquals(2, code.size());
|
||||
assertEquals("sql", code.get(0).getLanguage());
|
||||
assertEquals("""
|
||||
SELECT
|
||||
"line_sku" AS "sku",
|
||||
"quantity",
|
||||
"unit_price" AS "unitPrice"
|
||||
FROM "order_line"
|
||||
WHERE "line_sku" = 'AB''C-1'""", code.get(0).getCode());
|
||||
assertEquals("""
|
||||
SELECT
|
||||
"line_sku" AS "sku",
|
||||
"quantity",
|
||||
"unit_price" AS "unitPrice"
|
||||
FROM "order_line"
|
||||
LIMIT 100""", code.get(1).getCode());
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// the generated SQL runs on the backend and finds the row //
|
||||
////////////////////////////////////////////////////////////
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
|
||||
{
|
||||
try(ResultSet resultSet = statement.executeQuery(code.get(0).getCode()))
|
||||
{
|
||||
assertTrue(resultSet.next());
|
||||
assertEquals(SKU, resultSet.getString("sku"));
|
||||
assertEquals(3, resultSet.getInt("quantity"));
|
||||
assertFalse(resultSet.next());
|
||||
}
|
||||
try(ResultSet resultSet = statement.executeQuery(code.get(1).getCode()))
|
||||
{
|
||||
int rows = 0;
|
||||
while(resultSet.next())
|
||||
{
|
||||
rows++;
|
||||
}
|
||||
assertEquals(2, rows);
|
||||
}
|
||||
}
|
||||
|
||||
DeveloperPanelBlock keyValues = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.KEY_VALUES).findFirst().orElseThrow();
|
||||
assertEquals(BACKEND_NAME, keyValues.getItems().get(0).getValue());
|
||||
assertEquals("h2", keyValues.getItems().get(1).getValue());
|
||||
assertEquals("order_line", keyValues.getItems().get(2).getValue());
|
||||
assertEquals("No", keyValues.getItems().get(3).getValue());
|
||||
|
||||
DeveloperPanelBlock links = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.LINKS).findFirst().orElseThrow();
|
||||
assertEquals(2, links.getLinks().size());
|
||||
DeveloperPanelLink recordLink = links.getLinks().get(0);
|
||||
assertThat(recordLink.getHref()).startsWith("/app/sqlWorkbenchRunner?sql=").endsWith("&backend=h2").doesNotContain(" ", "+", "'", "\n");
|
||||
String sqlParam = recordLink.getHref().substring("/app/sqlWorkbenchRunner?sql=".length(), recordLink.getHref().indexOf("&backend="));
|
||||
assertEquals(code.get(0).getCode(), URLDecoder.decode(sqlParam, StandardCharsets.UTF_8));
|
||||
assertThat(links.getLinks().get(1).getHref()).contains("LIMIT%20100");
|
||||
|
||||
assertTrue(panel.getBlocks().stream().anyMatch(b -> b.getType() == DeveloperPanelBlockType.TEXT && b.getText().contains("runs only what you execute")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A prefixed workbench links to its prefixed Runner; the replica is reported.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPrefixAndReplica() throws Exception
|
||||
{
|
||||
config.setTableNamePrefix("ops");
|
||||
qInstance.getApp("sqlWorkbench").setName("ops_sqlWorkbench");
|
||||
qInstance.getApps().put("ops_sqlWorkbench", qInstance.getApps().remove("sqlWorkbench"));
|
||||
config.setUseBackendReadOnlyReplica(true);
|
||||
((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)).setReadOnlyBackendMetaData(defineH2Backend("h2Replica"));
|
||||
|
||||
DeveloperPanel panel = new SqlWorkbenchDeveloperPanelProvider().build(context("person", 7));
|
||||
String href = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.LINKS).findFirst().orElseThrow().getLinks().get(0).getHref();
|
||||
assertThat(href).startsWith("/app/ops_sqlWorkbenchRunner?sql=");
|
||||
assertThat(panel.getBlocks().get(0).getCode()).endsWith("WHERE \"id\" = 7");
|
||||
DeveloperPanelBlock keyValues = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.KEY_VALUES).findFirst().orElseThrow();
|
||||
assertEquals("Yes (h2Replica)", keyValues.getItems().get(3).getValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writers get the same (SELECT-only) panel.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWriterSameQueries() throws Exception
|
||||
{
|
||||
String readerSql = new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode();
|
||||
loginAs(WRITER);
|
||||
assertEquals(readerSql, new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Not available without the access permission, for a non-RDBMS backend,
|
||||
** for an excluded or not-allowed backend, or without the app.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotAvailable()
|
||||
{
|
||||
SqlWorkbenchDeveloperPanelProvider provider = new SqlWorkbenchDeveloperPanelProvider();
|
||||
assertTrue(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
|
||||
loginAs("nobody-1");
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
assertTrue(new DeveloperPanelsAction().execute(context(TABLE_NAME, SKU)).stream().noneMatch(p -> SqlWorkbenchDeveloperPanelProvider.ID.equals(p.getId())));
|
||||
|
||||
loginAs(READER);
|
||||
assertFalse(provider.isAvailable(context("memoryThing", 1)));
|
||||
|
||||
config.setExcludedBackendNames(List.of(BACKEND_NAME));
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
|
||||
config.setExcludedBackendNames(null);
|
||||
config.setAllowedBackendNames(List.of("someOtherBackend"));
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
|
||||
config.setAllowedBackendNames(null);
|
||||
qInstance.getApps().remove("sqlWorkbench");
|
||||
assertFalse(provider.isAvailable(context(TABLE_NAME, SKU)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier quoting and literals per dialect.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testQuotingAndLiterals()
|
||||
{
|
||||
assertEquals("`my``col`", RecordSql.quoteIdentifier(SqlDialect.MYSQL, "my`col"));
|
||||
assertEquals("\"my\"\"col\"", RecordSql.quoteIdentifier(SqlDialect.POSTGRES, "my\"col"));
|
||||
assertEquals("'a''b\\c'", RecordSql.stringLiteral(SqlDialect.POSTGRES, "a'b\\c"));
|
||||
assertEquals("'a''b\\\\c'", RecordSql.stringLiteral(SqlDialect.MYSQL, "a'b\\c"));
|
||||
assertEquals("42", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, 42));
|
||||
assertEquals("12.50", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.DECIMAL, new java.math.BigDecimal("12.50")));
|
||||
assertEquals("'1; DROP TABLE x'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, "1; DROP TABLE x"));
|
||||
assertEquals("'42'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, "42"));
|
||||
assertEquals("NULL", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, null));
|
||||
assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.GENERIC)).endsWith("FETCH FIRST 100 ROWS ONLY");
|
||||
assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.MYSQL)).contains("`first_name` AS `firstName`").endsWith("LIMIT 100");
|
||||
assertEquals("%27a%20b%27%26c%3D1", SqlWorkbenchDeveloperPanelProvider.encode("'a b'&c=1"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,6 +29,7 @@ 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.data.QRecord;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -73,7 +74,7 @@ class SqlExecutionEngineTest extends BaseSqlWorkbenchTest
|
||||
*******************************************************************************/
|
||||
private List<SqlWorkbenchExecution> audits() throws Exception
|
||||
{
|
||||
return (QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter()));
|
||||
return (SystemUser.call(() -> QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter())));
|
||||
}
|
||||
|
||||
|
||||
@@ -489,6 +490,99 @@ class SqlExecutionEngineTest extends BaseSqlWorkbenchTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** auditRetentionDays deletes audit rows older than the window, in the
|
||||
** background, and only those: a 40-day-old RUNNING row survives. The default
|
||||
** keeps everything, and a process does not prune the moment it starts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAuditRetentionPrunesOldRows() throws Exception
|
||||
{
|
||||
ExecutionAuditor auditor = new ExecutionAuditor(config);
|
||||
java.time.Instant old = java.time.Instant.now().minus(java.time.Duration.ofDays(40));
|
||||
for(String[] row : new String[][] { { "a1", "REJECTED" }, { "a2", "REJECTED" }, { "r1", "RUNNING" } })
|
||||
{
|
||||
auditor.record(new SqlWorkbenchExecution().withExecutionUuid("00000000-0000-0000-0000-0000000000" + row[0]).withUserId(READER).withBackendName(BACKEND_NAME)
|
||||
.withMode(ExecutionMode.READ_ONLY.name()).withStatus(row[1]).withSqlText("SELECT 1").withStartTime(old));
|
||||
}
|
||||
assertThat(audits()).hasSize(3);
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// default (null): nothing is ever pruned //
|
||||
////////////////////////////////////////////////////////////
|
||||
run("SELECT 1", Map.of(), READER);
|
||||
auditor.allowPruneNow();
|
||||
assertThat(auditor.pruneIfDue()).isNull();
|
||||
assertThat(audits()).hasSize(4);
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// 30 days: a fresh auditor waits out its start-up delay //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
config.setAuditRetentionDays(30);
|
||||
ExecutionAuditor pruning = new ExecutionAuditor(config);
|
||||
assertThat(pruning.pruneIfDue()).as("no pass right after start-up").isNull();
|
||||
|
||||
pruning.allowPruneNow();
|
||||
pruning.pruneIfDue().get();
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getExecutionUuid)
|
||||
.doesNotContain("00000000-0000-0000-0000-0000000000a1", "00000000-0000-0000-0000-0000000000a2")
|
||||
.contains("00000000-0000-0000-0000-0000000000r1");
|
||||
assertThat(audits()).as("the old RUNNING row and the fresh run").hasSize(2);
|
||||
assertThat(pruning.pruneIfDue()).as("at most hourly").isNull();
|
||||
assertThat(pruning.pruneOlderThan(java.time.Instant.now().minus(java.time.Duration.ofDays(30)))).as("RUNNING rows are never deleted").isZero();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// the pass that start() triggers runs in the background and spares RUNNING //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
auditor.record(new SqlWorkbenchExecution().withExecutionUuid("00000000-0000-0000-0000-0000000000a3").withUserId(READER).withBackendName(BACKEND_NAME)
|
||||
.withMode(ExecutionMode.READ_ONLY.name()).withStatus(ExecutionStatus.REJECTED.name()).withSqlText("SELECT 1").withStartTime(old));
|
||||
pruning.allowPruneNow();
|
||||
pruning.start(new SqlWorkbenchExecution().withExecutionUuid("00000000-0000-0000-0000-0000000000b1").withUserId(READER).withBackendName(BACKEND_NAME)
|
||||
.withMode(ExecutionMode.READ_ONLY.name()).withSqlText("SELECT 2").withStartTime(java.time.Instant.now()));
|
||||
for(int i = 0; i < 100 && audits().stream().anyMatch(a -> a.getExecutionUuid().endsWith("a3")); i++)
|
||||
{
|
||||
Thread.sleep(50);
|
||||
}
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getExecutionUuid).doesNotContain("00000000-0000-0000-0000-0000000000a3")
|
||||
.contains("00000000-0000-0000-0000-0000000000r1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A backlog bigger than one batch is deleted in bounded batches.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAuditRetentionDeletesInBatches() throws Exception
|
||||
{
|
||||
config.setAuditRetentionDays(30);
|
||||
ExecutionAuditor auditor = new ExecutionAuditor(config);
|
||||
java.time.Instant old = java.time.Instant.now().minus(java.time.Duration.ofDays(40));
|
||||
int total = ExecutionAuditor.BATCH_SIZE + 5;
|
||||
for(int i = 0; i < total; i++)
|
||||
{
|
||||
auditor.record(new SqlWorkbenchExecution().withExecutionUuid(java.util.UUID.randomUUID().toString()).withUserId(READER).withBackendName(BACKEND_NAME)
|
||||
.withMode(ExecutionMode.READ_ONLY.name()).withStatus(ExecutionStatus.REJECTED.name()).withSqlText("SELECT 1").withStartTime(old));
|
||||
}
|
||||
assertThat(auditor.pruneOlderThan(java.time.Instant.now().minus(java.time.Duration.ofDays(30)))).isEqualTo(total);
|
||||
assertThat(audits()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** auditRetentionDays must be positive when set.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAuditRetentionValidation()
|
||||
{
|
||||
java.util.List<String> errors = new java.util.ArrayList<>();
|
||||
new com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig().withBackendName(BACKEND_NAME).withAuditRetentionDays(0).validate(null, errors);
|
||||
assertThat(errors).anyMatch(e -> e.contains("auditRetentionDays"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A connection that cannot be opened answers with a generic message: the
|
||||
** driver's (URL, host, user) stays in the server log.
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHomeWidgetsMetaDataProducer;
|
||||
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.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
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.model.actions.tables.insert.InsertInput;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home dashboard widgets, rendered through RenderWidgetAction in each
|
||||
** viewer's session: real data, record locks (own runs; admins see all),
|
||||
** permission gates, and honest empty states.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchHomeTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private Integer orders;
|
||||
private Integer scratch;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** writer-1 owns "Orders report" (shared with reader-1) and "Private
|
||||
** scratch"; runs by writer-1 (read, write, failure) and reader-1 (one in
|
||||
** the window, one 40 days ago).
|
||||
*******************************************************************************/
|
||||
private void seed() throws Exception
|
||||
{
|
||||
loginAs(WRITER);
|
||||
orders = insert(SqlWorkbenchNotebook.TABLE_NAME, Map.of("title", "Orders report", "description", "Daily orders\nsecond line")).getValueInteger("id");
|
||||
scratch = insert(SqlWorkbenchNotebook.TABLE_NAME, Map.of("title", "Private scratch")).getValueInteger("id");
|
||||
insert(SqlWorkbenchNotebookShare.TABLE_NAME, Map.of("notebookId", orders, "audienceType", "USER", "audienceId", READER));
|
||||
|
||||
Instant now = Instant.now();
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
run(WRITER, "READ_ONLY", "SUCCEEDED", "SELECT * FROM orders WHERE total > 100", orders, 1500, now.minus(Duration.ofMinutes(5)), null, null);
|
||||
run(WRITER, "WRITE_COMMIT", "SUCCEEDED", "UPDATE person SET first_name = 'x'", null, 40, now.minus(Duration.ofMinutes(10)), 3, null);
|
||||
run(WRITER, "READ_ONLY", "FAILED", "SELEC broken", scratch, 5, now.minus(Duration.ofDays(3)), null, "syntax error at <x>");
|
||||
run(READER, "READ_ONLY", "SUCCEEDED", "SELECT count(*) FROM orders", orders, 50, now.minus(Duration.ofDays(1)), null, null);
|
||||
run(READER, "READ_ONLY", "SUCCEEDED", "SELECT 'old'", orders, 50, now.minus(Duration.ofDays(40)), null, null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert as the session.
|
||||
*******************************************************************************/
|
||||
private static QRecord insert(String table, Map<String, Serializable> values) throws QException
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
values.forEach(record::setValue);
|
||||
record = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords().get(0);
|
||||
assertThat(record.getErrors()).isNullOrEmpty();
|
||||
return (record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One audit row (as the system user: the audit is append-only).
|
||||
*******************************************************************************/
|
||||
private static void run(String user, String mode, String status, String sql, Integer notebookId, int durationMs, Instant start, Integer affected, String error) throws QException
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put("executionUuid", UUID.randomUUID().toString());
|
||||
values.put("userId", user);
|
||||
values.put("userName", "Name of " + user);
|
||||
values.put("backendName", BACKEND_NAME);
|
||||
values.put("mode", mode);
|
||||
values.put("status", status);
|
||||
values.put("sqlText", sql);
|
||||
values.put("notebookId", notebookId);
|
||||
values.put("durationMs", durationMs);
|
||||
values.put("startTime", start);
|
||||
values.put("endTime", start.plusMillis(durationMs));
|
||||
values.put("rowsReturned", affected == null ? 10 : null);
|
||||
values.put("rowsAffected", affected);
|
||||
values.put("errorMessage", error);
|
||||
values.put("createDate", start);
|
||||
insert(SqlWorkbenchExecution.TABLE_NAME, values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render a widget through the core action (widget permission checked).
|
||||
*******************************************************************************/
|
||||
private QWidgetData render(String widgetName, Map<String, String> params) throws QException
|
||||
{
|
||||
QWidgetMetaData widget = (QWidgetMetaData) qInstance.getWidget(widgetName);
|
||||
return (new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(widget).withUrlParams(new HashMap<>(params))).getWidgetData());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private QWidgetData render(String widgetName) throws QException
|
||||
{
|
||||
return (render(widgetName, Map.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All the HTML of a table widget's cells (and its footer).
|
||||
*******************************************************************************/
|
||||
private static String cells(QWidgetData data)
|
||||
{
|
||||
StringBuilder html = new StringBuilder();
|
||||
for(Map<String, Object> row : ((TableData) data).getRows())
|
||||
{
|
||||
row.values().forEach(v -> html.append(v).append('|'));
|
||||
html.append('\n');
|
||||
}
|
||||
return (html + String.valueOf(data.getFooterHTML()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSummaryForOwnerAndAdmin() throws Exception
|
||||
{
|
||||
seed();
|
||||
|
||||
loginAs(WRITER);
|
||||
String html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("Notebooks", ">2<", "2 yours, 0 shared with you", "Runs, 7 Days", "1 failed",
|
||||
"Writes, 30 Days", "3 rows affected", "/app/sqlWorkbenchRunner", "Open the Runner", "your own runs");
|
||||
|
||||
loginAs(READER);
|
||||
html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("0 yours, 1 shared with you", "0 failed", "0 rows affected");
|
||||
|
||||
loginAs(ADMIN);
|
||||
html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("0 yours, 2 by other users", "workbench admin", "3 rows affected");
|
||||
|
||||
config.setWriteModeEnabled(false);
|
||||
html = ((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml();
|
||||
assertThat(html).contains("write mode is off");
|
||||
assertThat(cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES))).contains("Write mode is off");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunsAreLockedToTheirUserExceptForAdmins() throws Exception
|
||||
{
|
||||
seed();
|
||||
|
||||
loginAs(WRITER);
|
||||
String runs = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS));
|
||||
assertThat(runs).contains("UPDATE person", "SELEC broken", "Orders report", "Private scratch", "you", "3 rows affected", "10 rows")
|
||||
.doesNotContain("count(*)");
|
||||
|
||||
loginAs(READER);
|
||||
runs = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS));
|
||||
assertThat(runs).contains("SELECT count(*) FROM orders", "SELECT 'old'", "Orders report").doesNotContain("UPDATE person");
|
||||
|
||||
loginAs(ADMIN);
|
||||
runs = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS));
|
||||
assertThat(runs).contains("UPDATE person", "count(*)", "Name of writer-1", "Name of reader-1");
|
||||
|
||||
TableData byUser = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER);
|
||||
assertThat(byUser.getRows()).hasSize(2);
|
||||
assertThat(cells(byUser)).contains("Name of writer-1", "writer-1", "Name of reader-1", "last run").doesNotContain("Only your own");
|
||||
assertThat(byUser.getColumns()).hasSize(4);
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// activity by user is admin-only (the widget's permission) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER)).isTrue();
|
||||
loginAs(WRITER);
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), SqlWorkbenchHomeWidgetsMetaDataProducer.BY_USER)).isFalse();
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).isTrue();
|
||||
SqlWorkbenchHome.Viewer viewer = SqlWorkbenchHome.viewer(config, Instant.now());
|
||||
String own = cells(SqlWorkbenchHome.load(SqlWorkbenchHome.KIND_BY_USER, viewer, new RenderWidgetInput()));
|
||||
assertThat(own).contains("Only your own runs").doesNotContain("reader-1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSlowestFailedWritesAndChart() throws Exception
|
||||
{
|
||||
seed();
|
||||
loginAs(WRITER);
|
||||
|
||||
TableData slowest = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SLOWEST);
|
||||
assertThat(slowest.getRows()).hasSize(3);
|
||||
assertThat(String.valueOf(slowest.getRows().get(0).get("duration"))).contains("1.5 s");
|
||||
assertThat(slowest.getLinkURL()).isEqualTo("/app/sql_workbench_execution");
|
||||
|
||||
String failed = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED));
|
||||
assertThat(failed).contains("syntax error at <x>", "failed", "/app/sql_workbench_execution/").doesNotContain("UPDATE");
|
||||
|
||||
String writes = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES));
|
||||
assertThat(writes).contains("UPDATE person", "<strong>3</strong>", "Write mode is on").doesNotContain("SELECT");
|
||||
|
||||
ChartData chart = (ChartData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RUNS_PER_DAY);
|
||||
assertThat(chart.getChartData().getLabels()).hasSize(30);
|
||||
assertThat(chart.getChartData().getDatasets()).extracting(ChartData.Data.Dataset::getLabel).containsExactly("Runs", "Failed", "Writes");
|
||||
assertThat(chart.getChartData().getDatasets().get(0).getData().stream().mapToLong(Number::longValue).sum()).isEqualTo(3);
|
||||
assertThat(chart.getChartData().getDatasets().get(1).getData().stream().mapToLong(Number::longValue).sum()).isEqualTo(1);
|
||||
assertThat(chart.getChartData().getDatasets().get(2).getData().stream().mapToLong(Number::longValue).sum()).isEqualTo(1);
|
||||
assertThat(chart.getSublabel()).isEqualTo("3 runs in the last 30 days, by day");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookWidgets() throws Exception
|
||||
{
|
||||
seed();
|
||||
|
||||
loginAs(READER);
|
||||
TableData recent = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_NOTEBOOKS);
|
||||
assertThat(recent.getRows()).hasSize(1);
|
||||
assertThat(cells(recent)).contains("Orders report", "Daily orders", "Name of writer-1", "shared", "/app/sqlWorkbenchRunner?notebook=" + orders)
|
||||
.doesNotContain("second line", "Private scratch");
|
||||
|
||||
String shared = cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.SHARED));
|
||||
assertThat(shared).contains("Orders report", "reader-1", "Name of writer-1");
|
||||
|
||||
loginAs(ADMIN);
|
||||
TableData mostUsed = (TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED);
|
||||
assertThat(mostUsed.getRows()).hasSize(2);
|
||||
assertThat(String.valueOf(mostUsed.getRows().get(0).get("notebook"))).contains("Orders report");
|
||||
assertThat(mostUsed.getRows().get(0).get("runs")).isEqualTo("2");
|
||||
assertThat(mostUsed.getRows().get(0).get("users")).isEqualTo("2");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// reader-1's own runs on a notebook they can't read: labeled //
|
||||
// honestly, not hidden //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
SystemUser.run(() -> run(READER, "READ_ONLY", "SUCCEEDED", "SELECT 1", scratch, 5, Instant.now(), null, null));
|
||||
loginAs(READER);
|
||||
assertThat(cells(render(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED))).contains("Notebook #" + scratch + " (deleted, or not visible to you)");
|
||||
|
||||
QWidgetData runner = render(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER, Map.of("id", String.valueOf(orders)));
|
||||
assertThat(((RawHTML) runner).getHtml()).contains("/app/sqlWorkbenchRunner?notebook=" + orders, "Open in Runner");
|
||||
assertThat(((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER)).getHtml()).contains("Save the notebook");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEmptyStatesAndPermissions() throws Exception
|
||||
{
|
||||
loginAs(READER);
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_NOTEBOOKS)).getNoRowsFoundHTML()).contains("No notebooks yet");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS)).getNoRowsFoundHTML()).contains("No runs yet");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES)).getNoRowsFoundHTML()).contains("No writes have been committed");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SHARED)).getNoRowsFoundHTML()).contains("No shared notebooks");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED)).getNoRowsFoundHTML()).contains("No notebook runs");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED)).getNoRowsFoundHTML()).contains("No failed runs");
|
||||
assertThat(((TableData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SLOWEST)).getNoRowsFoundHTML()).contains("No completed runs");
|
||||
assertThat(((ChartData) render(SqlWorkbenchHomeWidgetsMetaDataProducer.RUNS_PER_DAY)).getSublabel()).isEqualTo("No runs in the last 30 days");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// no workbench access: every widget is denied (the middleware checks //
|
||||
// widget permissions); rendered anyway, the locks show nothing //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
loginAs("nobody-1");
|
||||
for(String name : SqlWorkbenchHomeWidgetsMetaDataProducer.homeWidgetNames())
|
||||
{
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new RenderWidgetInput(), name)).as(name).isFalse();
|
||||
}
|
||||
assertThat(((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml()).contains("can't read");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// workbench access but no table read permissions: says so, no zeros //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference("limited-1"));
|
||||
session.setPermissions(new HashSet<>(List.of(config.getAccessPermissionName())));
|
||||
QContext.init(qInstance, session);
|
||||
assertThat(((RawHTML) render(SqlWorkbenchHomeWidgetsMetaDataProducer.SUMMARY)).getHtml()).contains("can't read");
|
||||
List<String> denied = new ArrayList<>();
|
||||
for(String name : List.of(SqlWorkbenchHomeWidgetsMetaDataProducer.RUNS_PER_DAY, SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_NOTEBOOKS,
|
||||
SqlWorkbenchHomeWidgetsMetaDataProducer.RECENT_RUNS, SqlWorkbenchHomeWidgetsMetaDataProducer.SLOWEST, SqlWorkbenchHomeWidgetsMetaDataProducer.FAILED,
|
||||
SqlWorkbenchHomeWidgetsMetaDataProducer.MOST_USED, SqlWorkbenchHomeWidgetsMetaDataProducer.SHARED, SqlWorkbenchHomeWidgetsMetaDataProducer.WRITES))
|
||||
{
|
||||
denied.add(((RawHTML) render(name)).getHtml());
|
||||
}
|
||||
assertThat(denied).allSatisfy(html -> assertThat(html).contains("You don't have permission to read"));
|
||||
|
||||
SqlWorkbenchHome.Viewer viewer = new SqlWorkbenchHome.Viewer(config, Instant.now(), ZoneId.of("UTC"), "x", false);
|
||||
assertThat(((RawHTML) SqlWorkbenchHome.load("nope", viewer, new RenderWidgetInput())).getHtml()).contains("Unknown");
|
||||
assertThat(((RawHTML) SqlWorkbenchHome.load(null, viewer, new RenderWidgetInput())).getHtml()).contains("Unknown");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testLabelsAndHtml()
|
||||
{
|
||||
assertThat(SqlWorkbenchHome.modeLabel("WRITE_PREVIEW")).isEqualTo("write preview");
|
||||
assertThat(SqlWorkbenchHome.modeLabel("EXPLAIN")).isEqualTo("explain");
|
||||
assertThat(SqlWorkbenchHome.modeLabel("EXPLAIN_ANALYZE")).isEqualTo("explain analyze");
|
||||
assertThat(SqlWorkbenchHome.modeLabel("SOMETHING_ELSE")).isEqualTo("something else");
|
||||
assertThat(SqlWorkbenchHome.modeLabel(null)).isEmpty();
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord())).isEmpty();
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord().withValue("status", "RUNNING"))).contains("running", HomeHtml.INFO_COLOR);
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord().withValue("status", "CANCELLED"))).contains(HomeHtml.OFF_COLOR);
|
||||
assertThat(SqlWorkbenchHome.statusBadge(new QRecord().withValue("status", "TIMED_OUT"))).contains("timed out", HomeHtml.BAD_COLOR);
|
||||
assertThat(SqlWorkbenchHome.firstLine(null, 5)).isNull();
|
||||
assertThat(SqlWorkbenchHome.firstLine("abcdefgh", 5)).isEqualTo("abcd…");
|
||||
|
||||
assertThat(HomeHtml.escape(null)).isEmpty();
|
||||
assertThat(HomeHtml.escape("<a href='x'>&\"")).isEqualTo("<a href='x'>&"");
|
||||
assertThat(HomeHtml.code("SELECT *\n FROM t", 100)).contains(">SELECT * FROM t<");
|
||||
assertThat(HomeHtml.code(null, 10)).contains("><");
|
||||
assertThat(HomeHtml.code("x".repeat(3000), 10)).contains("xxxxxxxxx…");
|
||||
assertThat(HomeHtml.duration(null)).isEmpty();
|
||||
assertThat(HomeHtml.duration(850)).isEqualTo("850 ms");
|
||||
assertThat(HomeHtml.duration(4200)).isEqualTo("4.2 s");
|
||||
assertThat(HomeHtml.duration(185_000)).isEqualTo("3m 05s");
|
||||
assertThat(HomeHtml.plural(1, "run")).isEqualTo("1 run");
|
||||
assertThat(HomeHtml.card("a", null, " ")).doesNotContain("·");
|
||||
|
||||
ZoneId utc = ZoneId.of("UTC");
|
||||
Instant now = Instant.parse("2026-10-12T15:00:00Z");
|
||||
assertThat(HomeHtml.when(null, now, utc)).isEmpty();
|
||||
assertThat(HomeHtml.when(Instant.parse("2026-10-12T09:05:00Z"), now, utc)).isEqualTo("9:05 AM");
|
||||
assertThat(HomeHtml.when(Instant.parse("2026-03-01T09:05:00Z"), now, utc)).isEqualTo("Mar 1, 9:05 AM");
|
||||
assertThat(HomeHtml.when(Instant.parse("2025-03-01T09:05:00Z"), now, utc)).isEqualTo("Mar 1, 2025, 9:05 AM");
|
||||
assertThat(HomeHtml.tiles(List.of(new HomeHtml.Tile("A", "1", null, null)))).contains(">1<").doesNotContain("href");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* 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.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchIntegrations;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
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.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
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.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
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.QAppChildMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The app's navigation (Runner plus the main tables only), the sub-tables as
|
||||
** child lists on a notebook's record view (still standard tables), and the
|
||||
** ESB / Quick Search hooks.
|
||||
*******************************************************************************/
|
||||
class AppNavigationTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private static final List<String> SUB_TABLES = List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNavListsTheRunnerAndMainTablesOnly()
|
||||
{
|
||||
QAppMetaData app = qInstance.getApp(SqlWorkbenchAppMetaDataProducer.APP_NAME);
|
||||
assertThat(app.getChildren()).extracting(QAppChildMetaData::getName)
|
||||
.containsExactly(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME, SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// no app anywhere lists a sub-table, yet each is a standard table //
|
||||
// with a record label (reachable by URL, the table API and search) //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
for(QAppMetaData any : qInstance.getApps().values())
|
||||
{
|
||||
assertThat(any.getChildren() == null ? List.<QAppChildMetaData>of() : any.getChildren()).extracting(QAppChildMetaData::getName)
|
||||
.as(any.getName()).doesNotContainAnyElementsOf(SUB_TABLES);
|
||||
}
|
||||
for(String subTable : SUB_TABLES)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(subTable);
|
||||
assertThat(table).as(subTable).isNotNull();
|
||||
assertThat(table.getRecordLabelFields()).as(subTable).isNotEmpty();
|
||||
assertThat(table.getField("notebookId").getPossibleValueSourceName()).isEqualTo(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookRecordViewShowsTheSubTablesAndRuns()
|
||||
{
|
||||
QTableMetaData notebook = qInstance.getTable(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
List<String> widgetSections = notebook.getSections().stream().map(QFieldSection::getWidgetName).filter(Objects::nonNull).toList();
|
||||
assertThat(widgetSections).containsExactly(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER,
|
||||
SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_CELL, SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_SHARE, SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_SNAPSHOT,
|
||||
SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_VERSION, SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION);
|
||||
|
||||
for(String joinName : widgetSections.subList(1, widgetSections.size()))
|
||||
{
|
||||
QWidgetMetaDataInterface widget = qInstance.getWidget(joinName);
|
||||
assertThat(widget.getType()).as(joinName).isEqualTo(WidgetType.CHILD_RECORD_LIST.getType());
|
||||
QJoinMetaData join = qInstance.getJoin(joinName);
|
||||
assertThat(join.getLeftTable()).isEqualTo(SqlWorkbenchNotebook.TABLE_NAME);
|
||||
assertThat(join.getType()).isEqualTo(JoinType.ONE_TO_MANY);
|
||||
}
|
||||
assertThat(qInstance.getJoin(SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION).getRightTable()).isEqualTo(SqlWorkbenchExecution.TABLE_NAME);
|
||||
assertThat(qInstance.getWidget(SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION).getLabel()).isEqualTo("Runs");
|
||||
assertThat(notebook.getSections().stream().filter(s -> s.getWidgetName() != null).map(QFieldSection::getLabel).toList())
|
||||
.containsExactly("Runner", "Cells", "Shares", "Snapshots (pinned results)", "Versions", "Runs");
|
||||
assertThat(notebook.getSections().get(0).getFieldNames()).containsExactly("id", "title", "description", "ownerName", "visibility");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Runs child list goes through the execution audit's lock: the
|
||||
** viewer's own runs of the notebook, every user's for an admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunsChildListIsLocked() throws Exception
|
||||
{
|
||||
loginAs(WRITER);
|
||||
QRecord notebook = new InsertAction().execute(new InsertInput(SqlWorkbenchNotebook.TABLE_NAME).withRecord(new QRecord().withValue("title", "Shared one"))).getRecords().get(0);
|
||||
Integer id = notebook.getValueInteger("id");
|
||||
new InsertAction().execute(new InsertInput(SqlWorkbenchNotebookShare.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("notebookId", id).withValue("audienceType", "USER").withValue("audienceId", READER)));
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
audit(WRITER, id);
|
||||
audit(READER, id);
|
||||
audit(READER, id);
|
||||
});
|
||||
|
||||
assertThat(runs(id)).hasSize(1);
|
||||
loginAs(READER);
|
||||
assertThat(runs(id)).hasSize(2).allSatisfy(r -> assertThat(r.getValueString("userId")).isEqualTo(READER));
|
||||
loginAs(ADMIN);
|
||||
assertThat(runs(id)).hasSize(3);
|
||||
assertThat(runs(id).get(0).getValueInteger("id")).isGreaterThan(runs(id).get(2).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testIntegrationHooks()
|
||||
{
|
||||
assertThat(SqlWorkbenchIntegrations.mainTableNames(config)).containsExactly(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
assertThat(SqlWorkbenchIntegrations.esbTableNames(config)).isEqualTo(SqlWorkbenchIntegrations.mainTableNames(config));
|
||||
|
||||
List<SqlWorkbenchIntegrations.SearchableTable> searchable = SqlWorkbenchIntegrations.quickSearchTables(config);
|
||||
assertThat(searchable).extracting(SqlWorkbenchIntegrations.SearchableTable::tableName).containsExactlyElementsOf(SqlWorkbenchIntegrations.mainTableNames(config));
|
||||
for(SqlWorkbenchIntegrations.SearchableTable table : searchable)
|
||||
{
|
||||
QTableMetaData tableMetaData = qInstance.getTable(table.tableName());
|
||||
assertThat(table.fieldWeights().keySet()).allSatisfy(field -> assertThat(tableMetaData.getFields()).containsKey(field));
|
||||
assertThat(tableMetaData.getFields()).containsKey(table.basepullTimestamp());
|
||||
assertThat(table.recordLabelFields()).allSatisfy(field -> assertThat(tableMetaData.getFields()).containsKey(field));
|
||||
}
|
||||
assertThat(searchable.get(0).fieldWeights()).containsEntry("title", 10).containsKey("description").containsKey("ownerName");
|
||||
assertThat(searchable.get(1).fieldWeights()).containsKey("sqlText").containsKey("userName").doesNotContainKey("parametersJson");
|
||||
|
||||
///////////////////////////////////////
|
||||
// the hooks follow the table prefix //
|
||||
///////////////////////////////////////
|
||||
SqlWorkbenchQBitConfig prefixed = new SqlWorkbenchQBitConfig().withTableNamePrefix("myNs");
|
||||
assertThat(SqlWorkbenchIntegrations.esbTableNames(prefixed)).containsExactly("my_ns_sql_workbench_notebook", "my_ns_sql_workbench_execution");
|
||||
assertThat(SqlWorkbenchIntegrations.quickSearchTables(prefixed).get(1).tableName()).isEqualTo("my_ns_sql_workbench_execution");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notebook's Runs child list as the session sees it.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> runs(Integer notebookId) throws QException
|
||||
{
|
||||
QWidgetMetaDataInterface widget = qInstance.getWidget(SqlWorkbenchTableMetaDataHelper.JOIN_NOTEBOOK_EXECUTION);
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("id", String.valueOf(notebookId));
|
||||
ChildRecordListData data = (ChildRecordListData) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(widget).withUrlParams(params)).getWidgetData();
|
||||
return (data.getQueryOutput().getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One audit row.
|
||||
*******************************************************************************/
|
||||
private static void audit(String user, Integer notebookId) throws QException
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put("executionUuid", UUID.randomUUID().toString());
|
||||
values.put("userId", user);
|
||||
values.put("mode", "READ_ONLY");
|
||||
values.put("status", "SUCCEEDED");
|
||||
values.put("sqlText", "SELECT 1");
|
||||
values.put("notebookId", notebookId);
|
||||
values.put("startTime", Instant.now());
|
||||
QRecord record = new QRecord();
|
||||
values.forEach(record::setValue);
|
||||
new InsertAction().execute(new InsertInput(SqlWorkbenchExecution.TABLE_NAME).withRecord(record));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,177 +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 java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchApi;
|
||||
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.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.TableQueryExecutor;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryInput;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryOutputInterface;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's tables are not reachable through the generic QQQ table API by
|
||||
** ordinary workbench users (only by holders of the admin permission), while
|
||||
** the workbench's own endpoints keep working for them.
|
||||
*******************************************************************************/
|
||||
class TableAccessTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private static final List<String> TABLES = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query a table the way /qqq/v1/table/{table}/query does.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> tableApiQuery(String tableName) throws Exception
|
||||
{
|
||||
List<QRecord> records = new ArrayList<>();
|
||||
TableQueryInput input = new TableQueryInput();
|
||||
input.setTableName(tableName);
|
||||
new TableQueryExecutor().execute(input, (TableQueryOutputInterface) records::addAll);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A writer's personal notebook (with a cell, a version and a pinned
|
||||
** snapshot) and an audited execution.
|
||||
*******************************************************************************/
|
||||
private Integer seedWriterData(SqlWorkbenchApi api) throws Exception
|
||||
{
|
||||
loginAs(WRITER);
|
||||
Map<String, Object> notebook = api.notebooks().create(Map.of("title", "Private payroll", "cells",
|
||||
List.of(Map.of("cellKey", "a", "cellType", "SQL", "content", "SELECT salary FROM person"))), caller(WRITER));
|
||||
Integer id = (Integer) notebook.get("id");
|
||||
|
||||
Map<String, Object> response = api.execute(caller(WRITER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT first_name, salary FROM person ORDER BY id"));
|
||||
assertThat(response.get("status")).isEqualTo("SUCCEEDED");
|
||||
api.createSnapshot(caller(WRITER), id, Map.of("executionUuid", response.get("executionUuid"), "cellKey", "a"));
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Another ordinary user cannot read the writer's personal notebook, cells,
|
||||
** versions, snapshot or audit row through the generic table API, nor
|
||||
** write any of those tables; the owner cannot either (no generic access
|
||||
** at all). The workbench API still hides the notebook from the other user.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOrdinaryUsersHaveNoGenericTableAccess() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Integer id = seedWriterData(api);
|
||||
|
||||
for(String user : List.of(OTHER_READER, WRITER))
|
||||
{
|
||||
loginAs(user);
|
||||
for(String table : TABLES)
|
||||
{
|
||||
assertThatThrownBy(() -> tableApiQuery(table)).as(user + " querying " + table).isInstanceOf(QPermissionDeniedException.class);
|
||||
for(TablePermissionSubType subType : List.of(TablePermissionSubType.READ, TablePermissionSubType.INSERT, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(table), table, subType)).as(user + " " + subType + " " + table).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// through the workbench API the notebook does not exist for the //
|
||||
// other reader, and their history holds none of the writer's //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
loginAs(OTHER_READER);
|
||||
assertThatThrownBy(() -> api.notebooks().get(id, caller(OTHER_READER))).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> api.notebooks().snapshots(id, caller(OTHER_READER))).isInstanceOf(QNotFoundException.class);
|
||||
assertThat(api.notebooks().list("mine", caller(OTHER_READER))).isEmpty();
|
||||
assertThat(api.notebooks().list("shared", caller(OTHER_READER))).isEmpty();
|
||||
assertThat((List<?>) api.history(caller(OTHER_READER), 50, null).get("executions")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench's own endpoints work for an ordinary user (session without
|
||||
** the admin permission): notebooks, versions, snapshots, history.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWorkbenchApiStillWorksForOrdinaryUsers() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Integer id = seedWriterData(api);
|
||||
|
||||
loginAs(WRITER);
|
||||
assertThat(api.notebooks().get(id, caller(WRITER)).get("title")).isEqualTo("Private payroll");
|
||||
assertThat(api.notebooks().update(id, Map.of("title", "Payroll"), caller(WRITER)).get("currentVersion")).isEqualTo(2);
|
||||
assertThat(api.notebooks().versions(id, caller(WRITER))).hasSize(2);
|
||||
assertThat(api.notebooks().snapshots(id, caller(WRITER))).hasSize(1);
|
||||
assertThat(api.notebooks().list("mine", caller(WRITER))).hasSize(1);
|
||||
assertThat((List<?>) api.history(caller(WRITER), 50, null).get("executions")).hasSize(1);
|
||||
|
||||
api.notebooks().publish(id, true, caller(WRITER));
|
||||
loginAs(READER);
|
||||
assertThat(api.notebooks().get(id, caller(READER)).get("canEdit")).isEqualTo(false);
|
||||
assertThat(api.notebooks().duplicate(id, caller(READER)).get("ownerUserId")).isEqualTo(READER);
|
||||
|
||||
loginAs(WRITER);
|
||||
api.notebooks().delete(id, caller(WRITER));
|
||||
assertThat(api.notebooks().list("mine", caller(WRITER))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A holder of the admin permission can read everything through the
|
||||
** generic table API.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAdminHasGenericTableAccess() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
seedWriterData(api);
|
||||
|
||||
loginAs(ADMIN);
|
||||
assertThat(tableApiQuery(SqlWorkbenchNotebook.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchCell.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchNotebookVersion.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchResultSnapshot.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchExecution.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user