Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2bd15b99 | ||
|
|
0c3fdd397b
|
||
|
|
ee2fd12aa1 | ||
|
|
60d8776564 | ||
|
|
6294c52eee | ||
|
|
422094fecb
|
||
|
|
b281741d2e | ||
|
|
47a3e62a45
|
||
|
|
7e9017c376
|
||
|
|
6da4fb400d
|
||
|
|
7c2d0610a5
|
||
|
|
268499aa74 | ||
|
|
d864eb996d | ||
|
|
aa13c1367e | ||
|
|
660278e020
|
||
|
|
c59b2ae72f | ||
|
|
103ef543d1
|
||
|
|
84a7033ae4
|
||
|
|
316a5d6f71 | ||
|
|
236fa901cf
|
||
|
|
5600496cce | ||
|
|
b0b027a9aa
|
||
|
|
6a0eae112c | ||
|
|
bb43871212
|
||
|
|
4d5139cab6 | ||
|
|
3ca94f7da5 | ||
|
|
cfb176e3ac | ||
|
|
c858bcb720
|
||
|
|
a0e3c222f7 | ||
|
|
e156a710e3
|
||
|
|
b531d9c046
|
||
|
|
9ca854e59a | ||
|
|
6029174344 | ||
|
|
1ffc663ba5 | ||
|
|
763c0e4352 | ||
|
|
d0153ebd9c | ||
|
|
dd4bd63b2b
|
||
|
|
0d8af84fbc
|
||
|
|
0d06a04953
|
No files matched your search
+2
-13
@@ -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:
|
||||
@@ -58,14 +58,3 @@ workflows:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
@@ -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 }}
|
||||
@@ -24,7 +24,7 @@ How tests are split:
|
||||
|
||||
**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.
|
||||
@@ -38,7 +38,7 @@ How tests are split:
|
||||
- `explain/`: `PlanParser` (Postgres JSON, MySQL JSON/text, SQLite rows, text) and `PlanNode`.
|
||||
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
|
||||
- `notebook/`: `NotebookService` (CRUD, sharing, versions in one transaction, snapshots), run as the caller's session through the standard table actions; `NotebookVersioning` (one version per change, as the system user).
|
||||
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
|
||||
- `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.
|
||||
@@ -65,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.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.
|
||||
- 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.
|
||||
|
||||
|
||||
+15
-4
@@ -2,9 +2,12 @@
|
||||
|
||||
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
|
||||
|
||||
## [1.0.0-RC.3] - Unreleased
|
||||
## [1.0.0-RC.4] - Unreleased
|
||||
|
||||
### Added
|
||||
- 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/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`.
|
||||
@@ -12,17 +15,25 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- **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 `_`.
|
||||
|
||||
### Deprecated
|
||||
- `LEGACY_TABLE_NAME` on each entity, `ShareAudienceType.LEGACY_NAME` and `SqlWorkbenchQBitConfig.LEGACY_*` hold the pre-RC.3 names, for migrating stored references only.
|
||||
### 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.
|
||||
|
||||
### Upgrade from RC.2
|
||||
### 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)).
|
||||
|
||||
@@ -16,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.4`, which is required: it lets partial edits of child rows pass joined record locks, [qqq#1073](https://github.com/QRun-IO/qqq/issues/1073)).
|
||||
- Java 21 and QQQ `4.1.0-RC.6` or later (the UI API extension contract, qqq#1128).
|
||||
- `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.
|
||||
|
||||
@@ -51,7 +51,7 @@ The producer adds the following to the instance:
|
||||
- **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.
|
||||
- **The `/qqq/v1/sql-workbench/*` routes**, registered through `QJavalinMetaData`, as `qqq-esb` does.
|
||||
- **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; the Runner moves with it. Open a notebook in the Runner with `/app/sqlWorkbenchRunner?notebook=<id>`.
|
||||
|
||||
@@ -101,6 +101,12 @@ 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/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.
|
||||
@@ -114,7 +120,7 @@ Everything is denied by default. Two standard QQQ layers apply:
|
||||
|
||||
| Role | Grant | What it allows |
|
||||
|---|---|---|
|
||||
| User | `config.getUserPermissionNames()`: `sql_workbench.hasAccess`, plus read/insert/edit/delete on notebooks and cells, read on versions, shares and the audit, and read/edit/delete on pinned results | The app, widget and endpoints (read-only SQL, EXPLAIN, schema). Their own notebooks, read-only access to notebooks shared with them or in the library, and their own execution history. |
|
||||
| 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. |
|
||||
@@ -231,7 +237,7 @@ A checklist for adding the workbench to a QQQ application. Each row points to th
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-sql-workbench`. Current release candidate: `1.0.0-RC.2` (Maven Central, published from `release/1.0`). |
|
||||
| QQQ version | QQQ 4.1, `4.1.0-RC.4` or later (qqq#1073: partial edits of child rows under joined locks). Java 21. |
|
||||
| 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. |
|
||||
@@ -244,7 +250,7 @@ A checklist for adding the workbench to a QQQ application. Each row points to th
|
||||
| ESB and search | Optional: wire `SqlWorkbenchIntegrations.esbTableNames` and `quickSearchTables` into your ESB and Quick Search config ([ESB and Quick Search](#esb-and-quick-search)). |
|
||||
| Turning writes off | `withWriteModeEnabled(false)` disables write mode for everyone. |
|
||||
| Known limitations | Raw SQL bypasses QQQ security, audits, listeners and automations ([Safety model](#safety-model)). H2 and generic backends have no enforced read-only transaction. Only PostgreSQL DDL ships. Shares are read-only (no shared editing). |
|
||||
| Upgrading | From RC.2: the QQQ names became snake_case (`sql_workbench_*`; no DDL). See [UPGRADE.md](UPGRADE.md) for permissions and stored table names. From RC.1: also run the share-table DDL and backfill, grant the new permission lists (the tables no longer rely on the admin `hasAccess` permission alone), call `applyToSession`, and use QQQ `4.1.0-RC.4` or later. |
|
||||
| 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.
|
||||
|
||||
|
||||
+9
-3
@@ -1,10 +1,16 @@
|
||||
# Upgrading
|
||||
|
||||
## To 1.0.0-RC.3: snake_case names (metadata only)
|
||||
## Table help and `auditRetentionDays` (unreleased)
|
||||
|
||||
The owner naming rule now applies to every QRun-IO qbit: table names are lowercase snake_case, prefixed with the qbit's name. In 1.0.0-RC.3 the SQL Workbench's QQQ names change to match. **The database does not change**: the physical tables were already `sql_workbench_*` (`docs/ddl/postgres.sql`), so no DDL runs. What changes is the names QQQ uses, and anything your app stored under the old names.
|
||||
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.
|
||||
|
||||
| Kind | Before (RC.1, RC.2) | From RC.3 |
|
||||
## 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` |
|
||||
|
||||
+4
-4
@@ -42,7 +42,7 @@ SQL Workbench is a super-user SQL notebook for any QQQ application. Users write
|
||||
│ └ WidgetRenderer │◄───────────────►│ /qqq/v1/metaData (app + widget, filtered │
|
||||
│ 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│
|
||||
@@ -73,8 +73,8 @@ 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)
|
||||
|
||||
@@ -190,7 +190,7 @@ Publishing in the notebook UI inserts (or deletes) the `ALL` share; the share cu
|
||||
| clientIp | STRING(64) | from the request, when available |
|
||||
| createDate | DATE_TIME | |
|
||||
|
||||
The audit table has its insert, edit, and delete capabilities disabled in the QQQ metadata, so the UI and generic API do not offer those operations, and its customizer rejects them in the core actions for every session but the system user (admins included). Its record lock shows each user their own rows (admins all). The engine writes it through QQQ actions with a system session context. The row is inserted **before** execution (`RUNNING`). If that insert fails, the SQL **does not run** (fail closed). The row is updated at the end. Retention: `auditRetentionDays` (default unlimited; the host prunes).
|
||||
The audit table has its insert, edit, and delete capabilities disabled in the QQQ metadata, so the UI and generic API 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,8 @@ 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);
|
||||
@@ -133,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,7 +18,9 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-SNAPSHOT</revision>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>1.0.0-RC.8</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
@@ -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>
|
||||
@@ -250,9 +252,9 @@
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer, runtime services, Javalin 7 -->
|
||||
<!-- routes; the record developer panel contract needs 4.1.0-RC.4). Until -->
|
||||
<!-- specs; the UI API extension contract needs 4.1.0-RC.6). Until -->
|
||||
<!-- qbit-build-parent 2.1.0 imports the released 4.1 BOM, this profile is -->
|
||||
<!-- active by default and imports qqq-bom-pom 4.1.0-RC.4 ahead of the -->
|
||||
<!-- active by default and imports qqq-bom-pom 4.1.0-RC.6 ahead of the -->
|
||||
<!-- parent's 4.0.0 BOM (same as qbit-quick-search, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
@@ -260,7 +262,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -71,23 +71,23 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
public static final String ALL_ACCESS_SECURITY_KEY_NAME = "sql_workbench_all_access";
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// names before 1.0.0-RC.3, when the owner rule (lowercase snake_case, //
|
||||
// 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.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_ACCESS_PERMISSION_BASE_NAME = "sqlWorkbench";
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_WRITE_PERMISSION_NAME = "sqlWorkbench.write";
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_SHARE_PERMISSION_NAME = "sqlWorkbench.share";
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_ADMIN_PERMISSION_BASE_NAME = "sqlWorkbenchAdmin";
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_USER_SECURITY_KEY_TYPE_NAME = "sqlWorkbenchUser";
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_AUDIENCE_SECURITY_KEY_TYPE_NAME = "sqlWorkbenchAudience";
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_ALL_ACCESS_SECURITY_KEY_NAME = "sqlWorkbenchAllAccess";
|
||||
|
||||
private String backendName;
|
||||
@@ -122,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;
|
||||
@@ -215,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)
|
||||
{
|
||||
@@ -377,8 +382,8 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
/*******************************************************************************
|
||||
** 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 / edit (label) /
|
||||
** delete on pinned snapshots. Record locks narrow every one of these to
|
||||
** 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
|
||||
@@ -389,7 +394,7 @@ public class SqlWorkbenchQBitConfig implements QBitConfig
|
||||
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", "edit", "delete");
|
||||
addTablePermissions(names, SqlWorkbenchResultSnapshot.TABLE_NAME, "read", "insert", "edit", "delete");
|
||||
addTablePermissions(names, SqlWorkbenchNotebookShare.TABLE_NAME, "read");
|
||||
addTablePermissions(names, SqlWorkbenchExecution.TABLE_NAME, "read");
|
||||
return (names);
|
||||
@@ -1480,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
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.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 +43,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 +183,18 @@ 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);
|
||||
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 +209,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,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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers SqlWorkbenchRouteProvider (the /qqq/v1/sql-workbench routes)
|
||||
** with the Javalin server through the instance's QJavalinMetaData - as
|
||||
** qqq-esb's EsbJavalinMetaDataProducer does. Only touched when
|
||||
** qqq-middleware-javalin is on the classpath (the producer checks first).
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRoutes
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the route provider reference, once.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
QJavalinMetaData javalinMetaData = QJavalinMetaData.ofOrWithNew(qInstance);
|
||||
boolean registered = CollectionUtils.nonNullList(javalinMetaData.getAdditionalRouteProviderReferences()).stream()
|
||||
.anyMatch(reference -> reference != null && SqlWorkbenchRouteProvider.class.getName().equals(reference.getName()));
|
||||
if(!registered)
|
||||
{
|
||||
javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(SqlWorkbenchRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -28,7 +28,7 @@ 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
|
||||
** 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
|
||||
|
||||
@@ -202,7 +202,7 @@ public class CellTableCustomizer extends WorkbenchTableCustomizer
|
||||
/*******************************************************************************
|
||||
** Type, content and key checks.
|
||||
*******************************************************************************/
|
||||
private static void validate(SqlWorkbenchQBitConfig config, QRecord record, boolean inserting)
|
||||
private static void validate(SqlWorkbenchQBitConfig config, QRecord record, boolean inserting) throws QException
|
||||
{
|
||||
if(inserting || record.getValues().containsKey("cellType"))
|
||||
{
|
||||
@@ -216,6 +216,7 @@ public class CellTableCustomizer extends WorkbenchTableCustomizer
|
||||
record.setValue("cellType", type);
|
||||
}
|
||||
|
||||
validateBackend(record, "backendName");
|
||||
if(inserting && record.getValue("content") == null)
|
||||
{
|
||||
record.setValue("content", "");
|
||||
|
||||
+2
-1
@@ -262,7 +262,7 @@ public class NotebookTableCustomizer extends WorkbenchTableCustomizer
|
||||
/*******************************************************************************
|
||||
** Title (required on insert, never blank) and parameters (a JSON list).
|
||||
*******************************************************************************/
|
||||
private static void validateContent(QRecord record, boolean inserting)
|
||||
private static void validateContent(QRecord record, boolean inserting) throws QException
|
||||
{
|
||||
if(inserting || record.getValues().containsKey("title"))
|
||||
{
|
||||
@@ -277,6 +277,7 @@ public class NotebookTableCustomizer extends WorkbenchTableCustomizer
|
||||
}
|
||||
}
|
||||
|
||||
validateBackend(record, "defaultBackendName");
|
||||
String parameters = record.getValueString("parametersJson");
|
||||
if(StringUtils.hasContent(parameters))
|
||||
{
|
||||
|
||||
+25
@@ -27,11 +27,14 @@ 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;
|
||||
@@ -278,4 +281,26 @@ public abstract class WorkbenchTableCustomizer implements TableCustomizerInterfa
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,134 @@
|
||||
/*
|
||||
* 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/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.
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9): replace with HelpBundles.register once the pinned QQQ
|
||||
** release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/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(!"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 || entry.path("content").asText().isBlank() || 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();
|
||||
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()));
|
||||
}
|
||||
|
||||
}
|
||||
+9
@@ -19,11 +19,16 @@ 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;
|
||||
|
||||
@@ -49,6 +54,10 @@ public class SqlWorkbenchSecurityAndJoinsMetaDataProducer extends QBitComponentM
|
||||
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);
|
||||
|
||||
+12
-5
@@ -27,6 +27,7 @@ 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;
|
||||
@@ -113,11 +114,12 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
.withSection(new QFieldSection("runner", "Runner", new QIcon("play_circle"), Tier.T2, null).withWidgetName(config.applyPrefix(SqlWorkbenchHomeWidgetsMetaDataProducer.NOTEBOOK_RUNNER)))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("defaultBackendName", "parametersJson")))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_CELL, "Cells", "notes"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SHARE, "Shared With", "share"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_SNAPSHOT, "Pinned Results", "push_pin"))
|
||||
.withSection(widgetSection(config, JOIN_NOTEBOOK_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");
|
||||
@@ -142,9 +144,12 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
.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("SQL");
|
||||
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));
|
||||
}
|
||||
@@ -233,6 +238,8 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
.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);
|
||||
}
|
||||
@@ -329,11 +336,11 @@ public class SqlWorkbenchTableMetaDataHelper
|
||||
.withOnlyIncludeFieldNames(List.of("id", "sortOrder", "cellType", "title", "backendName", "modifyDate"))
|
||||
.getWidgetMetaData());
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(config.applyPrefix(JOIN_NOTEBOOK_SHARE)))
|
||||
.withLabel("Shared With").withCanAddChildRecord(true)
|
||||
.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("Pinned Results")
|
||||
.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)))
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,9 @@ public enum ShareAudienceType implements PossibleValueEnum<String>
|
||||
public static final String NAME = "sql_workbench_share_audience_type";
|
||||
|
||||
/*******************************************************************************
|
||||
** The possible-value source name before 1.0.0-RC.3.
|
||||
** The possible-value source name before 1.0.0-RC.4.
|
||||
*******************************************************************************/
|
||||
@Deprecated(since = "1.0.0-RC.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_NAME = "sqlWorkbenchShareAudienceType";
|
||||
|
||||
private final String label;
|
||||
|
||||
@@ -32,10 +32,10 @@ public class SqlWorkbenchCell extends QRecordEntity
|
||||
public static final String TABLE_NAME = "sql_workbench_cell";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** 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.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchCell";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
|
||||
@@ -34,10 +34,10 @@ public class SqlWorkbenchExecution extends QRecordEntity
|
||||
public static final String TABLE_NAME = "sql_workbench_execution";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** 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.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchExecution";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
|
||||
@@ -33,10 +33,10 @@ public class SqlWorkbenchNotebook extends QRecordEntity
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** 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.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebook";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
|
||||
@@ -36,10 +36,10 @@ public class SqlWorkbenchNotebookShare extends QRecordEntity
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook_share";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** 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.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebookShare";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
|
||||
+2
-2
@@ -32,10 +32,10 @@ public class SqlWorkbenchNotebookVersion extends QRecordEntity
|
||||
public static final String TABLE_NAME = "sql_workbench_notebook_version";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** 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.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchNotebookVersion";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
|
||||
+2
-2
@@ -33,10 +33,10 @@ public class SqlWorkbenchResultSnapshot extends QRecordEntity
|
||||
public static final String TABLE_NAME = "sql_workbench_result_snapshot";
|
||||
|
||||
/*******************************************************************************
|
||||
** The table name before 1.0.0-RC.3 (owner rule: lowercase snake_case,
|
||||
** 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.3")
|
||||
@Deprecated(since = "1.0.0-RC.4")
|
||||
public static final String LEGACY_TABLE_NAME = "sqlWorkbenchResultSnapshot";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
|
||||
@@ -36,6 +36,7 @@ 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;
|
||||
@@ -89,8 +90,8 @@ 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;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
bundle: sql-workbench
|
||||
version: 1
|
||||
layer: QBIT
|
||||
# Help for the SQL Workbench app and every sql_workbench_* table, in the QQQ
|
||||
# help-bundle format (qqq#1198). Each table entry says what the table is for and
|
||||
# how its rows get created, so an empty table explains itself. SqlWorkbenchHelp
|
||||
# applies it until the core HelpBundles registry ships in a QQQ release.
|
||||
# Summaries stay under 120 characters (SqlWorkbenchTablesGuardrailTest).
|
||||
entries:
|
||||
- key: app:sqlWorkbench;slot:help
|
||||
summary: "Run SQL against your backends in notebooks: read-only by default, write mode gated, everything audited."
|
||||
content: |
|
||||
Nothing needs to be set up: every table here holds notebooks you write or a record of what ran. Open the Runner to write your first query and save it as a notebook. The Dashboard lists your notebooks and recent runs.
|
||||
|
||||
- key: table:sql_workbench_notebook;slot:help
|
||||
summary: "Saved SQL notebooks: a title, the target backend, parameters and sharing."
|
||||
content: |
|
||||
**How rows get created:** you create them. Open the Runner, write your SQL and Save, or use New here. Duplicate Notebook makes a personal copy of one you can read, and Share Notebook lets others read or edit yours. A new installation has none; this is your own content.
|
||||
|
||||
- key: table:sql_workbench_cell;slot:help
|
||||
summary: "The SQL and text cells that make up each notebook, in order."
|
||||
content: |
|
||||
**How rows get created:** by the notebook editor. Each cell you add in the Runner (SQL or markdown) is saved here with its notebook. You rarely edit this table directly.
|
||||
|
||||
- key: table:sql_workbench_notebook_version;slot:help
|
||||
summary: "Automatic history of each notebook: one version per saved change, newest kept."
|
||||
content: |
|
||||
**How rows get created:** by the workbench, never by hand. Every save of a notebook writes a version, and the oldest are pruned beyond notebookVersionRetention (100 per notebook by default). Use Restore Version to bring an old one back as a new version.
|
||||
|
||||
- key: table:sql_workbench_notebook_share;slot:help
|
||||
summary: "Who a notebook is shared with: a user, a group, or everyone, and whether they can edit."
|
||||
content: |
|
||||
**How rows get created:** use Share Notebook on a notebook you own. Each share adds a row for the user, group or everyone. Notebooks are private until shared.
|
||||
|
||||
- key: table:sql_workbench_result_snapshot;slot:help
|
||||
summary: "Result tables you pinned to a notebook so the data is kept with it."
|
||||
content: |
|
||||
**How rows get created:** run a query in the Runner and pin its result to the notebook (Snapshot). The snapshot keeps the audited SQL and up to snapshotMaxRows rows. It stays empty until someone pins a result.
|
||||
|
||||
- key: table:sql_workbench_execution;slot:help
|
||||
summary: "The audit of every statement run: who, where, the SQL, the outcome and how long it took."
|
||||
content: |
|
||||
**How rows get created:** by the workbench itself, before each run, including rejected requests; nobody can add, edit or delete rows. It stays empty until someone runs a query. Set auditRetentionDays in the qbit config to delete rows older than that many days (the default keeps them forever).
|
||||
File diff suppressed because it is too large.
Load diff
@@ -203,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.
|
||||
|
||||
@@ -20,21 +20,13 @@ package com.kingsrook.qbits.sqlworkbench;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetRenderer;
|
||||
import com.kingsrook.qbits.sqlworkbench.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;
|
||||
@@ -42,8 +34,20 @@ 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.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;
|
||||
@@ -108,7 +112,8 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
QContext.clear();
|
||||
assertThat(com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper.getAllAvailablePermissionNames(qInstance))
|
||||
.contains("sql_workbench_notebook.read", "sql_workbench_notebook_share.insert", "sql_workbench.hasAccess")
|
||||
.doesNotContain("sql_workbench_execution.delete", "sql_workbench_notebook_version.insert");
|
||||
.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);
|
||||
@@ -132,7 +137,7 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
assertThat(runner.getPermissionRules().getPermissionBaseName()).isEqualTo("sql_workbench");
|
||||
assertThat(app.getChildren()).noneMatch(child -> child instanceof QProcessMetaData);
|
||||
|
||||
assertThat(QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences()).isNotEmpty();
|
||||
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();
|
||||
@@ -155,7 +160,11 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
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();
|
||||
@@ -169,9 +178,9 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner naming rule (1.0.0-RC.3): table, possible-value source, permission
|
||||
** 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.3 names stay available only as deprecated constants.
|
||||
** name; the pre-RC.4 names stay available only as deprecated constants.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
@@ -227,6 +236,70 @@ class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -490,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.
|
||||
|
||||
@@ -118,7 +118,7 @@ class AppNavigationTest extends BaseSqlWorkbenchTest
|
||||
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", "Shared With", "Pinned Results", "Versions", "Runs");
|
||||
.containsExactly("Runner", "Cells", "Shares", "Snapshots (pinned results)", "Versions", "Runs");
|
||||
assertThat(notebook.getSections().get(0).getFieldNames()).containsExactly("id", "title", "description", "ownerName", "visibility");
|
||||
}
|
||||
|
||||
|
||||
+35
-1
@@ -53,6 +53,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat;
|
||||
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.delete.DeleteOutput;
|
||||
@@ -86,7 +87,6 @@ import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryInput;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryOutputInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.TableUpdateInput;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.TableUpdateOutputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -991,4 +991,38 @@ class TableSecurityMatrixTest extends BaseSqlWorkbenchTest
|
||||
static final List<String> ALL = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchNotebookShare.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both insert and update paths reject backend text outside the live PVS.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBackendSelectionValidation() throws Exception
|
||||
{
|
||||
login(OWNER);
|
||||
Integer notebookId = ids.get("Private");
|
||||
for(String invalid : List.of("unknown", "", BACKEND_NAME))
|
||||
{
|
||||
config.setExcludedBackendNames(invalid.equals(BACKEND_NAME) ? List.of(BACKEND_NAME) : List.of());
|
||||
QRecord notebook = new InsertAction().execute(new InsertInput(SqlWorkbenchNotebook.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("title", "Invalid backend").withValue("defaultBackendName", invalid))).getRecords().get(0);
|
||||
assertThat(notebook.getErrors()).isNotEmpty();
|
||||
QRecord changed = new UpdateAction().execute(new UpdateInput(SqlWorkbenchNotebook.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("id", notebookId).withValue("defaultBackendName", invalid))).getRecords().get(0);
|
||||
assertThat(changed.getErrors()).isNotEmpty();
|
||||
QRecord cell = new InsertAction().execute(new InsertInput(SqlWorkbenchCell.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("notebookId", notebookId).withValue("backendName", invalid))).getRecords().get(0);
|
||||
assertThat(cell.getErrors()).isNotEmpty();
|
||||
Integer cellId = QueryAction.execute(SqlWorkbenchCell.TABLE_NAME,
|
||||
new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, notebookId))).get(0).getValueInteger("id");
|
||||
QRecord changedCell = new UpdateAction().execute(new UpdateInput(SqlWorkbenchCell.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("id", cellId).withValue("backendName", invalid))).getRecords().get(0);
|
||||
assertThat(changedCell.getErrors()).isNotEmpty();
|
||||
}
|
||||
config.setExcludedBackendNames(List.of());
|
||||
QRecord valid = new InsertAction().execute(new InsertInput(SqlWorkbenchCell.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("notebookId", notebookId).withValue("backendName", BACKEND_NAME))).getRecords().get(0);
|
||||
assertThat(valid.getErrors()).isNullOrEmpty();
|
||||
assertThat(valid.getValueString("cellKey")).isNotBlank();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -219,6 +219,36 @@ class NotebookServiceTest extends BaseSqlWorkbenchTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With QQQ core permission enforcement on, an ordinary user's permission set
|
||||
** (getUserPermissionNames) is enough for the whole notebook flow, including
|
||||
** pinning a result (a top-level snapshot insert).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUserPermissionsSufficeWithCoreEnforcement() throws Exception
|
||||
{
|
||||
qInstance.setEnforcePermissionsInCoreActions(true);
|
||||
try
|
||||
{
|
||||
Integer id = (Integer) service.create(body("Enforced", List.of(cell("c1", "SQL", "SELECT * FROM person"))), as(READER)).get("id");
|
||||
service.update(id, Map.of("title", "Enforced v2"), as(READER));
|
||||
|
||||
SqlExecutionEngine engine = new SqlExecutionEngine(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Map<String, Object> response = engine.execute(ExecutionRequest.readOnly(BACKEND_NAME, "SELECT * FROM person", Map.of()), as(READER));
|
||||
|
||||
Map<String, Object> snapshot = service.createSnapshot(id, Map.of("executionUuid", response.get("executionUuid"), "cellKey", "c1"), as(READER), "SELECT * FROM person");
|
||||
assertThat(snapshot.get("id")).isNotNull();
|
||||
service.deleteSnapshot(id, (Integer) snapshot.get("id"), as(READER));
|
||||
service.delete(id, as(READER));
|
||||
}
|
||||
finally
|
||||
{
|
||||
qInstance.setEnforcePermissionsInCoreActions(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -70,6 +70,8 @@ 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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook_share (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL,
|
||||
@@ -117,6 +119,8 @@ 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);
|
||||
|
||||
CREATE TABLE person (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL PRIMARY KEY,
|
||||
|
||||
Reference in new issue
Block a user