Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de4631fb51
|
||
|
|
2e535d5037 | ||
|
|
f47c40ffbf | ||
|
|
15844af91e | ||
|
|
2b7edc25a5 | ||
|
|
f98d8124f4
|
||
|
|
677f800015 | ||
|
|
56b17b5768
|
||
|
|
ad4a492c31 | ||
|
|
912840a22d
|
||
|
|
db4715c906
|
||
|
|
3fcff49d4f | ||
|
|
ae54611acf
|
||
|
|
7304aaf5dd | ||
|
|
e783261d2d
|
||
|
|
ee2fd12aa1 | ||
|
|
60d8776564 | ||
|
|
b281741d2e | ||
|
|
47a3e62a45
|
||
|
|
7e9017c376
|
||
|
|
6da4fb400d
|
||
|
|
7c2d0610a5
|
||
|
|
268499aa74 |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# 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:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -5,6 +5,9 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [1.0.0-RC.4] - Unreleased
|
||||
|
||||
### Added
|
||||
- Host-neutral in-app help bundle `help/qbit-sql-workbench.yaml`, registered with QQQ help bundles, with live references and coverage checks for workbench metadata and generated table actions. Replaces the earlier minimal bundle while retaining inline app/table help for hosts without help storage. Requires QQQ `4.1.0-RC.7`.
|
||||
- Table help: the SQL Workbench app and every `sql_workbench_*` table carry help saying what the table is for and how its rows get created (`help/qbit-sql-workbench.yaml`, QQQ help-bundle format, applied by `SqlWorkbenchHelp`; host help is never overridden). `SqlWorkbenchTablesGuardrailTest` fails when a table is unused by runtime code, unexplained, or has no such help. All six tables are user content or logs and start empty by design; nothing is seeded.
|
||||
- `auditRetentionDays` config (default null: keep forever, no change for existing hosts): when set, execution audit rows older than that many days are deleted, in the background in bounded batches, at most once an hour per process (first pass 5 minutes after start), triggered as a run is audited; rows still RUNNING are kept. DESIGN.md already described this setting; it did not exist before.
|
||||
- OpenAPI coverage for all 21 SQL Workbench UI API operations, including streaming CSV/JSON/XLSX export. The `sql-workbench` extension preserves `/qqq/v1/sql-workbench`, existing request/response shapes, permission and ownership checks, exact decimal parameters, execution limits and audit. The generated fragment is in `src/main/resources/openapi/qqq-v1/sql-workbench.yaml`; strict-server security tests and a fragment contract test guard it.
|
||||
- **App home dashboard.** The `sqlWorkbench` app's home is now an information dashboard of real data, read in the viewer's session (permissions and record locks apply): summary tiles and links, runs per day, recent notebooks, recent runs, slowest and failed queries, most-used notebooks, shared notebooks, committed writes, and (workbench admins only) activity by user. Each widget has an honest empty state.
|
||||
- **Runner.** The notebook editor (`SQL_WORKBENCH` widget) moves to its own nav entry: the `sqlWorkbenchRunner` child app, labeled Runner. Open a notebook with `/app/sqlWorkbenchRunner?notebook=<id>`.
|
||||
@@ -13,6 +16,7 @@ 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
|
||||
- UI API extension sql-workbench 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and deprecated SqlWorkbenchErrorV1 retained for compatibility. Existing operationIds, HTTP statuses and success payloads are preserved.
|
||||
- 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.
|
||||
|
||||
@@ -16,7 +16,7 @@ The full design (architecture, data model, security model, limits, API contract,
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21 and QQQ `4.1.0-RC.6` or later (the UI API extension contract, qqq#1128).
|
||||
- Java 21 and QQQ `4.1.0-RC.7` or later (UI API extensions and help bundles).
|
||||
- `qqq-backend-module-rdbms` for the targets. `qqq-middleware-javalin` for the REST routes; it is optional, and without it the engine still works but no routes are registered.
|
||||
- A Next UI release that renders the `SQL_WORKBENCH` widget.
|
||||
|
||||
@@ -55,6 +55,14 @@ The producer adds the following to the instance:
|
||||
|
||||
To move the app under another app (for example an Admin app), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing; the Runner moves with it. Open a notebook in the Runner with `/app/sqlWorkbenchRunner?notebook=<id>`.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-sql-workbench.yaml](src/main/resources/help/qbit-sql-workbench.yaml), a `QBIT` bundle with summaries, Markdown help and live metadata references. Hosts with `helpContent` load help and call `HelpBundles.syncOnStartup(qInstance)` after defining metadata. Registration itself needs no help table; app/table inline help remains available without one.
|
||||
|
||||
To override wording, register your own resource with `HelpBundles.register(qInstance, "help/my-host.yaml")`, set `layer: HOST`, and use the same entry `key` and `role` (default `ALL_SCREENS`). HOST text takes precedence over QBIT text; user-edited help is preserved by sync. Keep the bundle's literal version aligned with the POM version when releasing.
|
||||
|
||||
`SqlWorkbenchHelpBundleTest` uses `HelpBundleAssertions` to check ownership coverage, keys, summary lengths and live references, including generated table actions. Run it with `./mvnw -q test -Dtest=SqlWorkbenchHelpBundleTest`; it also verifies registration without a help table.
|
||||
|
||||
## App home
|
||||
|
||||
The app's home page is a dashboard of real data, read through QQQ's core actions in the viewer's session. Permissions and record locks therefore decide what each widget counts: a user sees their own runs and the notebooks they can open, and a workbench admin sees everyone's. A widget whose table the user may not read says so rather than showing zeros. Every widget is full width and has an empty state.
|
||||
@@ -101,6 +109,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/qbit-sql-workbench.yaml`, shown on the table screen) says how its rows get created. No host include or action is needed.
|
||||
|
||||
Retention: `notebookVersionRetention` (default 100) prunes the oldest versions of a notebook on save. `auditRetentionDays` (default null: keep forever) deletes execution audit rows older than that many days, in the background in bounded batches, at most once an hour per process, triggered as a run is audited.
|
||||
|
||||
## Storage upgrade from 1.0.0-RC.1
|
||||
|
||||
RC.2 adds `sql_workbench_notebook_share`. The DDL creates it and backfills an `all` share (the shared library) for every notebook whose `visibility` is `SHARED`; it is additive and idempotent, and owners (`owner_user_id`) are unchanged. Run it before deploying RC.2: without the share rows, published notebooks are visible only to their owners.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Upgrading
|
||||
|
||||
## Table help and `auditRetentionDays` (unreleased)
|
||||
|
||||
No action needed. The qbit now fills empty `help` slots on its app and tables (help a host already set is kept). The new `auditRetentionDays` config defaults to null, which keeps every audit row as before; set it to bound the size of `sql_workbench_execution`. `notebookVersionRetention` (default 100 per notebook) was already enforced. There is no DDL change and no seed.
|
||||
|
||||
## To 1.0.0-RC.4: snake_case names (metadata only)
|
||||
|
||||
The rename shipped in 1.0.0-RC.4. 1.0.0-RC.3 on Maven Central is an accidental republish of the RC.2 code (see CHANGELOG.md), so upgrading from RC.3 is the same as upgrading from RC.2.
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -156,6 +156,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>sql-workbench.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
@@ -163,6 +164,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>sql-workbench.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
@@ -252,9 +254,9 @@
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer, runtime services, Javalin 7 -->
|
||||
<!-- specs; the UI API extension contract needs 4.1.0-RC.6). Until -->
|
||||
<!-- specs and help bundles require 4.1.0-RC.15). 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.6 ahead of the -->
|
||||
<!-- active by default and imports qqq-bom-pom 4.1.0-RC.15 ahead of the -->
|
||||
<!-- parent's 4.0.0 BOM (same as qbit-quick-search, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
@@ -262,7 +264,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -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
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -25,9 +25,11 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchHelp;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchTableMetaDataHelper;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
@@ -182,6 +184,14 @@ public class SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkben
|
||||
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// after the tables and app are in the instance: fill their empty help //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
SqlWorkbenchHelp.apply(qInstance, qBitConfig);
|
||||
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-sql-workbench.yaml");
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ public class SqlWorkbenchEndpoints
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("sql-workbench").withPathPrefix("/sql-workbench")
|
||||
.withSemanticVersion("1.0.0").withDescription("Audited SQL execution and notebooks")
|
||||
.withSemanticVersion("1.1.0").withDescription("Audited SQL execution and notebooks")
|
||||
.withSpec(GetCapabilitiesSpec.class)
|
||||
.withSpec(GetSchemaSpec.class)
|
||||
.withSpec(ExecuteSpec.class)
|
||||
|
||||
@@ -33,6 +33,7 @@ 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.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
@@ -155,11 +156,11 @@ public abstract class SqlWorkbenchJsonSpec extends AbstractJsonEndpointSpec
|
||||
*******************************************************************************/
|
||||
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")));
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input or execution limit exceeded", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible resource", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "Execution UUID already used", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ public class SqlWorkbenchSchemas
|
||||
{
|
||||
Map<String, Schema> schemas = new LinkedHashMap<>();
|
||||
schemas.put("SqlWorkbenchErrorV1", new Schema().withType(Type.OBJECT)
|
||||
.withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")
|
||||
.withProperty("error", new Schema().withType(Type.STRING)));
|
||||
schemas.put("SqlWorkbenchEmptyRequestV1", new Schema().withType(Type.OBJECT));
|
||||
schemas.put("SqlWorkbenchDeletedV1", new Schema().withType(Type.OBJECT)
|
||||
|
||||
+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
|
||||
|
||||
@@ -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,138 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-sql-workbench.yaml (the QQQ help-bundle format) to the SQL
|
||||
** Workbench app and its sql_workbench_* tables: what each table is for and
|
||||
** how its rows get created, shown on the table screen and when it is empty.
|
||||
** Retains inline app/table help for hosts without helpContent, including
|
||||
** prefixed metadata. Core bundle registration supplies the full help catalog.
|
||||
*******************************************************************************/
|
||||
public final class SqlWorkbenchHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-sql-workbench.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = SqlWorkbenchHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-sql-workbench".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every sql_workbench_* table in the instance
|
||||
** (names carry the config's prefix, if any). A host's own help on a table
|
||||
** is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
|
||||
{
|
||||
String key = entry.getKey();
|
||||
if(!key.endsWith(";slot:help"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String name = key.substring(key.indexOf(':') + 1, key.indexOf(';'));
|
||||
if(key.startsWith("table:"))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(config.applyPrefix(name));
|
||||
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry.getValue()));
|
||||
}
|
||||
}
|
||||
else if(key.startsWith("app:"))
|
||||
{
|
||||
QAppMetaData app = qInstance.getApp(config.applyPrefix(name));
|
||||
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(entry.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
bundle: qbit-sql-workbench
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:sqlWorkbench;slot:help
|
||||
summary: 'A SQL notebook for your databases: write queries, run them read-only, and review every run afterwards.'
|
||||
content: |
|
||||
The home page is a dashboard of your notebooks and recent runs (admins see everyone's). Use **{{label:sqlWorkbenchRunner}}** to write and run SQL, **{{label:sql_workbench_notebook}}** to manage saved notebooks, and **{{label:sql_workbench_execution}}** to see who ran what.
|
||||
|
||||
**Safe by default.** Runs are read-only and one statement at a time. Writing to the database needs write mode enabled by your administrator, the separate write permission and two steps: a dry run that is rolled back, then a confirmation.
|
||||
|
||||
**Who sees what.** You see your own notebooks, notebooks shared with you, and your own runs. Administrators see everything.
|
||||
|
||||
Raw SQL bypasses normal application record rules, history and search updates. Use application processes for business changes. Every run is recorded in the execution audit.
|
||||
- key: app:sqlWorkbenchRunner;slot:help
|
||||
summary: 'The notebook editor: write SQL and notes in cells, run them, and see results, charts and query plans.'
|
||||
content: |
|
||||
Open a notebook from **{{label:sql_workbench_notebook}}** (the **Open in Runner** link) or start a new one here.
|
||||
|
||||
- A notebook is a list of cells. A SQL cell holds a query, a Markdown cell holds notes.
|
||||
- Each SQL cell runs against one database; read-only is the default.
|
||||
- Named parameters written as :name in the SQL are always bound as values, never pasted into the text.
|
||||
- Saving creates a new version. Results can be pinned as snapshots and exported.
|
||||
|
||||
Only one statement runs per cell in read-only mode, and your administrator sets the limit on simultaneous runs.
|
||||
- key: table:sql_workbench_notebook;slot:help
|
||||
summary: 'A saved SQL notebook: its title, default database, parameters and cells, owned by one person.'
|
||||
content: |
|
||||
**How rows get created:** You create a notebook in the Runner or with New here. It starts with your own content.
|
||||
|
||||
Notebooks are personal until the owner shares them. You can read your own notebooks and ones shared with you; only the owner (or an administrator) can edit or delete one.
|
||||
|
||||
The record view lists the notebook's **Cells**, **Shares**, **Snapshots (pinned results)**, **Versions** and **Runs**, and has an **Open in Runner** link. Actions: **{{label:sqlWorkbenchShareNotebook}}** and **{{label:sqlWorkbenchDuplicateNotebook}}**.
|
||||
|
||||
Creating a notebook here makes you the owner and saves version 1. Deleting a notebook also deletes its cells, versions, pinned results and shares. Past runs stay in **{{label:sql_workbench_execution}}**.
|
||||
- key: table:sql_workbench_notebook;section:identity
|
||||
summary: The notebook's title, description, owner and who can read it.
|
||||
- key: table:sql_workbench_notebook;section:runner
|
||||
summary: A button to open this notebook in the Runner editor.
|
||||
- key: table:sql_workbench_notebook;section:details
|
||||
summary: The default database and the named parameters the notebook's SQL can use.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_cell
|
||||
summary: The SQL and Markdown cells of this notebook, in order.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_notebook_share
|
||||
summary: Who this notebook is shared with, read-only.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_result_snapshot
|
||||
summary: Query results pinned from this notebook so they stay viewable later.
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_notebook_version
|
||||
summary: The saved history of this notebook, newest first (the latest 25 are shown).
|
||||
- key: table:sql_workbench_notebook;section:sql_workbench_notebook_join_sql_workbench_execution
|
||||
summary: The most recent runs of this notebook (latest 25), taken from the execution audit.
|
||||
- key: table:sql_workbench_notebook;section:system
|
||||
summary: Owner id, current version number and created and changed dates, all filled in automatically.
|
||||
- key: table:sql_workbench_notebook;field:title
|
||||
summary: The name shown in the notebook list and the Runner.
|
||||
content: |
|
||||
Required. Surrounding spaces are removed and it cannot be blank. Up to {{maxLength:sql_workbench_notebook.title}} characters.
|
||||
- key: table:sql_workbench_notebook;field:description
|
||||
summary: A short note about what the notebook is for.
|
||||
- key: table:sql_workbench_notebook;field:defaultBackendName
|
||||
summary: The database new SQL cells run against unless a cell picks its own.
|
||||
content: |
|
||||
Leave it blank to choose per cell. Your administrator controls which databases are available.
|
||||
- key: table:sql_workbench_notebook;field:parametersJson
|
||||
summary: Named parameters for the SQL, as a JSON list with a name, type, default value and label for each.
|
||||
content: |
|
||||
Reference a parameter in SQL as :name. Values are always bound safely, never pasted into the statement. The Runner has a parameters panel that edits this for you; if you type it by hand it must be a valid JSON list or the save is refused.
|
||||
- key: table:sql_workbench_notebook;field:ownerUserId
|
||||
summary: The sign-in id of the owner, set automatically from whoever created the notebook.
|
||||
content: |
|
||||
Only an administrator can change it, which transfers the notebook to someone else (the owner name then follows the new id).
|
||||
- key: table:sql_workbench_notebook;field:ownerName
|
||||
summary: The owner's display name, copied from their account when the notebook was created.
|
||||
- key: table:sql_workbench_notebook;field:currentVersion
|
||||
summary: The number of the newest saved version, increased by every save.
|
||||
- key: table:sql_workbench_notebook;field:visibility
|
||||
summary: Whether the notebook is in the shared library or limited to its owner, administrators and other share audiences.
|
||||
content: |
|
||||
- **PERSONAL**: not published to everyone.
|
||||
- **SHARED**: shared with Everyone (the shared library).
|
||||
|
||||
You cannot edit this directly. It follows the notebook's shares: add an Everyone share to publish it, remove that share to make it personal again.
|
||||
- key: table:sql_workbench_notebook_share;slot:help
|
||||
summary: One person, group or everyone who may read a notebook, read-only.
|
||||
content: |
|
||||
**How rows get created:** You use Share Notebook or add a share from the notebook's Shares list.
|
||||
|
||||
A share never lets anyone edit the notebook; it only lets them open and run it. Add one from a notebook's **Shares** list or with the **{{label:sqlWorkbenchShareNotebook}}** action. You need the share permission, and only the notebook's owner or an administrator can add or remove shares.
|
||||
|
||||
Share with Everyone to make the notebook available in the shared library.
|
||||
- key: table:sql_workbench_notebook_share;section:identity
|
||||
summary: The notebook and who it is shared with.
|
||||
- key: table:sql_workbench_notebook_share;section:system
|
||||
summary: The matching key, who added the share and when, all filled in automatically.
|
||||
- key: table:sql_workbench_notebook_share;field:audienceType
|
||||
summary: Whether the share goes to one person, a group, or everyone.
|
||||
content: |
|
||||
- **User**: one person, named in User or Group.
|
||||
- **Group**: a sign-in group, named in User or Group.
|
||||
- **Everyone (shared library)**: all workbench users; no name needed. This publishes the notebook to the shared library.
|
||||
- key: table:sql_workbench_notebook_share;field:audienceId
|
||||
summary: The user id or group name to share with; leave blank for Everyone.
|
||||
content: |
|
||||
Required for User and Group shares. Matching ignores upper and lower case and surrounding spaces, so Admins and admins are the same group.
|
||||
- key: table:sql_workbench_notebook_share;field:notebookId
|
||||
summary: The notebook being shared. It cannot be moved to another notebook after the share is created.
|
||||
- key: table:sql_workbench_notebook_share;field:audienceKey
|
||||
summary: The normalized form of the audience, such as user:name, group:name or all, used for access checks.
|
||||
content: |
|
||||
Filled in automatically from the type and name.
|
||||
- key: table:sql_workbench_notebook_share;field:createdByUserId
|
||||
summary: The sign-in id of the person who added the share, set automatically.
|
||||
- key: table:sql_workbench_notebook_share;field:createdByName
|
||||
summary: The display name of the person who added the share, set automatically.
|
||||
- key: table:sql_workbench_cell;slot:help
|
||||
summary: 'One block of a notebook: a SQL query or a Markdown note.'
|
||||
content: |
|
||||
**How rows get created:** You add SQL or Markdown cells to a notebook in the Runner or from its Cells list.
|
||||
|
||||
Cells are normally edited in the Runner, but you can add, edit, reorder and delete them here. A notebook can hold at most 200 cells, and your administrator sets the SQL length limit.
|
||||
|
||||
Every add, edit or delete saves a new notebook version, just like saving in the Runner. A new cell is added at the end with a generated key. Only the notebook's owner or an administrator can change cells.
|
||||
- key: table:sql_workbench_cell;section:identity
|
||||
summary: Which notebook the cell belongs to, its position, type and title.
|
||||
- key: table:sql_workbench_cell;section:content
|
||||
summary: The SQL or Markdown text and the database the cell runs against.
|
||||
- key: table:sql_workbench_cell;section:system
|
||||
summary: The cell's generated key and created and changed dates, filled in automatically.
|
||||
- key: table:sql_workbench_cell;field:notebookId
|
||||
summary: The notebook this cell belongs to. A cell cannot be moved to a different notebook.
|
||||
- key: table:sql_workbench_cell;field:cellKey
|
||||
summary: A generated unique key that stays with the cell. Pinned results and run history refer to it.
|
||||
content: |
|
||||
Set automatically when the cell is created and it cannot be changed.
|
||||
- key: table:sql_workbench_cell;field:sortOrder
|
||||
summary: The cell's position in the notebook, lowest first.
|
||||
content: |
|
||||
Left blank on a new cell it is placed at the end. Change the numbers to reorder cells.
|
||||
- key: table:sql_workbench_cell;field:cellType
|
||||
summary: Whether the cell is runnable SQL or a Markdown note.
|
||||
content: |
|
||||
- **SQL**: a query that you can run. Default: {{default:sql_workbench_cell.cellType}}.
|
||||
- **Markdown**: formatted notes that are displayed, never run.
|
||||
- key: table:sql_workbench_cell;field:title
|
||||
summary: "An optional heading for the cell."
|
||||
content: |
|
||||
Give the cell an optional heading of up to {{maxLength:sql_workbench_cell.title}} characters.
|
||||
- key: table:sql_workbench_cell;field:content
|
||||
summary: The SQL text or Markdown. SQL can use :name parameters defined on the notebook.
|
||||
content: |
|
||||
Your administrator sets the maximum SQL length.
|
||||
- key: table:sql_workbench_cell;field:backendName
|
||||
summary: The database this SQL cell runs against; blank uses the notebook's default.
|
||||
- key: table:sql_workbench_notebook_version;slot:help
|
||||
summary: 'A read-only history entry: the full content of a notebook as it was at one save.'
|
||||
content: |
|
||||
**How rows get created:** The workbench saves a version when you create or change a notebook or its cells.
|
||||
|
||||
A new version is written each time the notebook is saved, a cell is added, edited or removed, or an older version is restored. It holds the title, description, parameters and all cells. You cannot edit or delete versions yourself. Older versions are pruned according to your administrator's retention setting.
|
||||
|
||||
To go back, run **{{label:sqlWorkbenchRestoreNotebookVersion}}** on the version you want. That saves it as a new, latest version, while retained versions remain available.
|
||||
- key: table:sql_workbench_notebook_version;section:identity
|
||||
summary: Which notebook and version number this is, and the note saved with it.
|
||||
- key: table:sql_workbench_notebook_version;section:content
|
||||
summary: The saved notebook content as JSON.
|
||||
- key: table:sql_workbench_notebook_version;section:system
|
||||
summary: Who saved this version and when, filled in automatically.
|
||||
- key: table:sql_workbench_notebook_version;field:notebookId
|
||||
summary: The notebook this version belongs to.
|
||||
- key: table:sql_workbench_notebook_version;field:versionNumber
|
||||
summary: The version's number, counting up from 1 within its notebook.
|
||||
- key: table:sql_workbench_notebook_version;field:comment
|
||||
summary: A short note on why the version was saved, such as Created, Cell added, Cell edited or Restored version 3.
|
||||
- key: table:sql_workbench_notebook_version;field:contentJson
|
||||
summary: The notebook's title, description, default database, parameters and every cell, saved as JSON.
|
||||
- key: table:sql_workbench_notebook_version;field:savedByUserId
|
||||
summary: The sign-in id of the person who saved this version.
|
||||
- key: table:sql_workbench_notebook_version;field:savedByName
|
||||
summary: The display name of the person who saved this version.
|
||||
- key: table:sql_workbench_result_snapshot;slot:help
|
||||
summary: A query result pinned from a notebook so you can look at it later without re-running the SQL.
|
||||
content: |
|
||||
**How rows get created:** You run a query in the Runner and pin its result to your notebook.
|
||||
|
||||
Pin a result from the Runner after running a cell. A snapshot keeps the column list, rows up to the configured snapshot limit, the SQL that produced it and the database it came from. It contains real query data, so it is visible only to the notebook's readers and administrators.
|
||||
|
||||
You cannot add snapshots here, and the only thing you can edit is the **{{label:sql_workbench_result_snapshot.label}}**. Delete a snapshot you no longer need.
|
||||
- key: table:sql_workbench_result_snapshot;section:identity
|
||||
summary: Which notebook and cell the result came from and its label.
|
||||
- key: table:sql_workbench_result_snapshot;section:result
|
||||
summary: The pinned rows, the SQL and database that produced them, and whether the result was cut short.
|
||||
- key: table:sql_workbench_result_snapshot;section:system
|
||||
summary: Who pinned the result and when, filled in automatically.
|
||||
- key: table:sql_workbench_result_snapshot;field:notebookId
|
||||
summary: The notebook the result was pinned from.
|
||||
- key: table:sql_workbench_result_snapshot;field:cellKey
|
||||
summary: The key of the notebook cell that produced the result.
|
||||
- key: table:sql_workbench_result_snapshot;field:label
|
||||
summary: "A name you give the pinned result to find it again."
|
||||
content: |
|
||||
A name you give the pinned result to find it again. This is the only editable field. Up to {{maxLength:sql_workbench_result_snapshot.label}} characters.
|
||||
- key: table:sql_workbench_result_snapshot;field:backendName
|
||||
summary: The database the SQL ran against, recorded when the result was pinned.
|
||||
- key: table:sql_workbench_result_snapshot;field:executionUuid
|
||||
summary: The id of the run that produced this result; it matches a row in SQL Execution Audit.
|
||||
- key: table:sql_workbench_result_snapshot;field:sqlText
|
||||
summary: The SQL that produced the pinned result, as it was when you pinned it.
|
||||
- key: table:sql_workbench_result_snapshot;field:resultJson
|
||||
summary: The pinned columns and rows as JSON, up to the configured snapshot limit.
|
||||
- key: table:sql_workbench_result_snapshot;field:rowCount
|
||||
summary: How many rows the snapshot holds.
|
||||
- key: table:sql_workbench_result_snapshot;field:truncated
|
||||
summary: Yes when the result had more rows than could be kept, so the snapshot holds only the first part.
|
||||
- key: table:sql_workbench_result_snapshot;field:createdByUserId
|
||||
summary: The sign-in id of the person who pinned the result.
|
||||
- key: table:sql_workbench_execution;slot:help
|
||||
summary: 'The audit log of SQL runs: who ran what, where, how long it took and how it ended.'
|
||||
content: |
|
||||
**How rows get created:** The workbench records runs before executing SQL, including rejected SQL attempts.
|
||||
|
||||
Every run is recorded before the SQL starts, and attempts that were refused are logged too, so this is the place to answer "who changed or read this?". Rows are added and updated by the workbench only; you cannot edit or delete them. Your administrator can configure automatic removal of older completed runs.
|
||||
|
||||
You see your own runs; administrators see everyone's. The log keeps the SQL text and parameter values, but never the result rows.
|
||||
|
||||
Use the filters on **{{label:sql_workbench_execution.status}}**, **{{label:sql_workbench_execution.mode}}**, **User** and **{{label:sql_workbench_execution.startTime}}** to narrow it down, or open a run from a notebook's **Runs** list.
|
||||
- key: table:sql_workbench_execution;section:identity
|
||||
summary: The run's id, mode, outcome and database.
|
||||
- key: table:sql_workbench_execution;section:run
|
||||
summary: Who ran it, when, how long it took, and how many rows came back or changed.
|
||||
- key: table:sql_workbench_execution;section:sql
|
||||
summary: The SQL text, its fingerprint, the parameter values used and any error.
|
||||
- key: table:sql_workbench_execution;section:system
|
||||
summary: When the audit row was created, filled in automatically.
|
||||
- key: table:sql_workbench_execution;field:executionUuid
|
||||
summary: A unique id for the run, also used to fetch its results and match pinned snapshots.
|
||||
- key: table:sql_workbench_execution;field:mode
|
||||
summary: How the SQL was run.
|
||||
content: |
|
||||
- **READ_ONLY**: a normal read-only run.
|
||||
- **WRITE_PREVIEW**: a dry run of a write, rolled back so you can see the affected-row counts.
|
||||
- **WRITE_COMMIT**: the confirmed write, committed.
|
||||
- **EXPLAIN**: a query plan without running the query.
|
||||
- **EXPLAIN_ANALYZE**: a query plan that actually runs the query (needs the write permission).
|
||||
- key: table:sql_workbench_execution;field:status
|
||||
summary: How the run ended, or Running while it is still going.
|
||||
content: |
|
||||
- **RUNNING**: in progress. A run that never finishes may stay here.
|
||||
- **SUCCEEDED**: finished normally.
|
||||
- **FAILED**: the database returned an error (see Error).
|
||||
- **CANCELLED**: someone stopped it.
|
||||
- **TIMED_OUT**: it went over the time limit configured by your administrator.
|
||||
- **REJECTED**: refused before running, for example the SQL was not a plain read.
|
||||
- **ROLLED_BACK**: a write preview, undone on purpose.
|
||||
- **DRIFTED**: the data changed between preview and confirm, so the write was rolled back.
|
||||
- key: table:sql_workbench_execution;field:backendName
|
||||
summary: The database the SQL ran against.
|
||||
- key: table:sql_workbench_execution;field:userId
|
||||
summary: The sign-in id of the person who ran it.
|
||||
- key: table:sql_workbench_execution;field:userName
|
||||
summary: The display name of the person who ran it, as it was at the time.
|
||||
- key: table:sql_workbench_execution;field:startTime
|
||||
summary: When the run started.
|
||||
- key: table:sql_workbench_execution;field:endTime
|
||||
summary: When the run finished; blank while it is still running.
|
||||
- key: table:sql_workbench_execution;field:durationMs
|
||||
summary: How long the run took, in milliseconds.
|
||||
- key: table:sql_workbench_execution;field:rowsReturned
|
||||
summary: How many result rows a read returned, up to the configured row limit.
|
||||
- key: table:sql_workbench_execution;field:rowsAffected
|
||||
summary: How many rows a write changed or, for a preview, would change.
|
||||
- key: table:sql_workbench_execution;field:truncated
|
||||
summary: Yes when the result hit the row or size limit and was cut short.
|
||||
- key: table:sql_workbench_execution;field:statementCount
|
||||
summary: How many SQL statements the run contained. Read-only runs allow one.
|
||||
- key: table:sql_workbench_execution;field:statementKinds
|
||||
summary: The kinds of statement the run was classified as, separated by commas.
|
||||
- key: table:sql_workbench_execution;field:notebookId
|
||||
summary: The notebook the run came from; blank for SQL run outside a notebook.
|
||||
- key: table:sql_workbench_execution;field:cellKey
|
||||
summary: The key of the notebook cell that was run, if any.
|
||||
- key: table:sql_workbench_execution;field:sqlText
|
||||
summary: The exact SQL that was run (or refused). It can contain sensitive literals, so treat it as confidential.
|
||||
- key: table:sql_workbench_execution;field:sqlHash
|
||||
summary: A fingerprint of the database, SQL and parameters, useful for spotting repeated queries.
|
||||
- key: table:sql_workbench_execution;field:parametersJson
|
||||
summary: The values bound to the named parameters for this run, stored as written.
|
||||
- key: table:sql_workbench_execution;field:errorMessage
|
||||
summary: The error text when the run failed, timed out or was rejected, cut off at 4,000 characters.
|
||||
- key: table:sql_workbench_execution;field:clientIp
|
||||
summary: The network address the request came from, when it was available.
|
||||
- key: process:sqlWorkbenchShareNotebook;slot:help
|
||||
summary: Share the selected notebook, read-only, with one user, one group or everyone who can use the SQL Workbench.
|
||||
content: |
|
||||
Select one notebook, choose who to share with, and run. Recipients can open the notebook but cannot change it; to edit, they duplicate it into their own copy. Only the owner or an administrator changes the original.
|
||||
|
||||
Sharing with a **User** gives that person read access. Sharing with **Everyone** puts it in the shared library. Sharing needs the notebook share permission. To stop sharing, delete the share under **Shares** on the notebook.
|
||||
- key: process:sqlWorkbenchShareNotebook;field:audienceType
|
||||
summary: 'Who gets access: one user, one group, or everyone.'
|
||||
content: |
|
||||
Choose **User** or **Group** and fill in the next field, or **Everyone (shared library)** to share with every workbench user.
|
||||
- key: process:sqlWorkbenchShareNotebook;field:audienceId
|
||||
summary: The user name or group name to share with; leave blank when sharing with everyone.
|
||||
content: |
|
||||
Names are not case sensitive. Required for User and Group.
|
||||
- key: process:sqlWorkbenchDuplicateNotebook;slot:help
|
||||
summary: Make your own copy of a notebook you can open, with all its cells, parameters and default database.
|
||||
content: |
|
||||
The copy is titled "Copy of" followed by the original title, is owned by you, and starts at version 1 with the note "Duplicated from notebook" and the original's number. Sharing, pinned results and run history are not copied. Duplicate a shared notebook to change it, since shared access is read-only. Works on one notebook at a time.
|
||||
- key: process:sqlWorkbenchRestoreNotebookVersion;slot:help
|
||||
summary: Bring back an earlier saved version of a notebook you own.
|
||||
content: |
|
||||
Select one version under **Versions** and run. The notebook's content goes back to that version and is saved as a new version, with the comment "Restored version" and the number. Other retained versions stay in the history, subject to the retention limit. You must own the notebook to use this action.
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_cell;slot:label
|
||||
summary: 'The notebook''s cells, in order: each is a block of SQL or Markdown notes.'
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_notebook_share;slot:label
|
||||
summary: 'Who this notebook is shared with: users, groups, or everyone.'
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_result_snapshot;slot:label
|
||||
summary: Query results pinned to this notebook, kept as they were when pinned.
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_notebook_version;slot:label
|
||||
summary: Every saved version of this notebook, newest first; use Restore Version to bring one back.
|
||||
- key: widget:sql_workbench_notebook_join_sql_workbench_execution;slot:label
|
||||
summary: Recent runs of this notebook's queries, with who ran them and how they ended.
|
||||
- key: widget:sqlWorkbenchWidget;slot:label
|
||||
summary: 'The SQL Workbench runner: write, run and save SQL notebooks against your available databases.'
|
||||
- key: widget:sqlWorkbenchHomeSummary;slot:label
|
||||
summary: Counts of notebooks, runs today, runs and failures in the last 7 days, and writes in the last 30 days.
|
||||
- key: widget:sqlWorkbenchHomeRunsPerDay;slot:label
|
||||
summary: A line chart of runs, failures and committed writes for each of the last 30 days.
|
||||
- key: widget:sqlWorkbenchHomeRecentNotebooks;slot:label
|
||||
summary: The 10 notebooks changed most recently that you can open.
|
||||
- key: widget:sqlWorkbenchHomeRecentRuns;slot:label
|
||||
summary: The 10 most recent query runs you can see.
|
||||
- key: widget:sqlWorkbenchHomeSlowestQueries;slot:label
|
||||
summary: The 10 slowest completed query runs of the last 30 days.
|
||||
- key: widget:sqlWorkbenchHomeFailedQueries;slot:label
|
||||
summary: The latest failed, timed-out, rejected or rolled-back runs of the last 30 days, with the error.
|
||||
- key: widget:sqlWorkbenchHomeMostUsedNotebooks;slot:label
|
||||
summary: The notebooks with the most runs in the last 30 days, with the number of users and the last run.
|
||||
- key: widget:sqlWorkbenchHomeSharedNotebooks;slot:label
|
||||
summary: Notebooks that have been shared, who owns them, who they are shared with and since when.
|
||||
- key: widget:sqlWorkbenchHomeWrites;slot:label
|
||||
summary: 'Changes committed to the database through the workbench: who, what, when, and how many rows.'
|
||||
- key: widget:sqlWorkbenchHomeActivityByUser;slot:label
|
||||
summary: Runs, failures and writes per user over the last 30 days; visible to workbench admins.
|
||||
- key: widget:sqlWorkbenchNotebookOpenInRunner;slot:label
|
||||
summary: A button on a notebook that opens it in the runner to edit and run its cells.
|
||||
- key: possibleValueSource:sql_workbench_share_audience_type;slot:help
|
||||
summary: Who a notebook can be shared with.
|
||||
content: |
|
||||
- **User**: one named person.
|
||||
- **Group**: everyone in a named group.
|
||||
- **Everyone (shared library)**: every workbench user.
|
||||
- key: possibleValueSource:sql_workbench_cell_type;slot:help
|
||||
summary: What a notebook cell holds.
|
||||
content: |
|
||||
- **SQL**: a query you can run.
|
||||
- **Markdown**: formatted notes that are not run.
|
||||
- key: possibleValueSource:sql_workbench_backend;slot:help
|
||||
summary: The databases your administrator makes available for workbench queries.
|
||||
- key: possibleValueSource:sql_workbench_notebook;slot:help
|
||||
summary: Choose a notebook you can access by its title.
|
||||
- key: process:sql_workbench_notebook.bulkInsert;slot:help
|
||||
summary: Create notebooks from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook}}**. Review the input before creating records. The same permissions and validation apply as when you add a single record.
|
||||
- key: process:sql_workbench_notebook.bulkEdit;slot:help
|
||||
summary: Change selected notebooks together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_notebook}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit.
|
||||
- key: process:sql_workbench_notebook.bulkEditWithFile;slot:help
|
||||
summary: Update notebooks using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply.
|
||||
- key: process:sql_workbench_notebook.bulkDelete;slot:help
|
||||
summary: Delete selected notebooks.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_notebook}}** before confirming deletion. You can delete only records you have permission to remove. Deleting a notebook also removes its cells, shares, snapshots and versions; execution audit rows remain subject to retention.
|
||||
- key: process:sql_workbench_cell.bulkInsert;slot:help
|
||||
summary: Create cells from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_cell}}**. Review the input before creating records. The same permissions and validation apply as when you add a single record.
|
||||
- key: process:sql_workbench_cell.bulkEdit;slot:help
|
||||
summary: Change selected cells together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_cell}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit.
|
||||
- key: process:sql_workbench_cell.bulkEditWithFile;slot:help
|
||||
summary: Update cells using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_cell}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply.
|
||||
- key: process:sql_workbench_cell.bulkDelete;slot:help
|
||||
summary: Delete selected cells.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_cell}}** before confirming deletion. You can delete only records you have permission to remove.
|
||||
- key: process:sql_workbench_notebook_share.bulkInsert;slot:help
|
||||
summary: Create shares from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook_share}}**. Review the input before creating records. The same permissions and validation apply as when you add a single record.
|
||||
- key: process:sql_workbench_notebook_share.bulkEdit;slot:help
|
||||
summary: Change selected shares together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_notebook_share}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit.
|
||||
- key: process:sql_workbench_notebook_share.bulkEditWithFile;slot:help
|
||||
summary: Update shares using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_notebook_share}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply.
|
||||
- key: process:sql_workbench_notebook_share.bulkDelete;slot:help
|
||||
summary: Delete selected shares.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_notebook_share}}** before confirming deletion. You can delete only records you have permission to remove. Removing a share ends that access grant; other shares can still allow access.
|
||||
- key: process:sql_workbench_result_snapshot.bulkEdit;slot:help
|
||||
summary: Change selected snapshots together.
|
||||
content: |
|
||||
Select the records to change in **{{label:sql_workbench_result_snapshot}}**, choose the fields and enter their new values. Review your changes before applying them. You can change only records you have permission to edit. Only the snapshot label is editable.
|
||||
- key: process:sql_workbench_result_snapshot.bulkEditWithFile;slot:help
|
||||
summary: Update snapshots using values from a file.
|
||||
content: |
|
||||
Upload your file and map its columns to the fields in **{{label:sql_workbench_result_snapshot}}**. Match the rows to existing records and review the changes. Your normal edit permissions and field validation still apply. Only the snapshot label is editable.
|
||||
- key: process:sql_workbench_result_snapshot.bulkDelete;slot:help
|
||||
summary: Delete selected snapshots.
|
||||
content: |
|
||||
Review your selected records in **{{label:sql_workbench_result_snapshot}}** before confirming deletion. You can delete only records you have permission to remove.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -203,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.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Checks the shipped help against the metadata produced by this QBit.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchHelpBundleTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Owned objects and their non-trivial fields must have valid help.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bundleCoversWorkbench()
|
||||
{
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles("help/qbit-sql-workbench.yaml")
|
||||
.owning(target -> target.name().startsWith("sqlWorkbench") || target.name().startsWith("sql_workbench_"))
|
||||
.assertComplete();
|
||||
|
||||
// Core requires apps, tables, fields and processes; also cover our widgets and value sources.
|
||||
var keys = HelpBundles.of(qInstance).getBundles().get(0).getEntries().stream().map(entry -> entry.getKey()).toList();
|
||||
HelpTargetCatalog.collect(qInstance).stream()
|
||||
.filter(target -> target.kind().equals("widget") || target.kind().equals("possibleValueSource"))
|
||||
.filter(target -> target.name().startsWith("sqlWorkbench") || target.name().startsWith("sql_workbench_"))
|
||||
.forEach(target -> assertThat(keys).as(target.key()).contains(target.key()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration remains useful and startup sync is harmless without storage.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registersWithoutHelpContentTable()
|
||||
{
|
||||
assertThat(qInstance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-sql-workbench");
|
||||
assertThat(bundle.getVersion()).isEqualTo(SqlWorkbenchQBitProducer.getVersion());
|
||||
assertThat(bundle.getLayer().name()).isEqualTo("QBIT");
|
||||
});
|
||||
assertThatCode(() -> HelpBundles.syncOnStartup(qInstance)).doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -50,7 +51,7 @@ class SqlWorkbenchApiContractTest
|
||||
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.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("SqlWorkbench") || name.equals(BasicErrorResponseV1.class.getSimpleName()));
|
||||
assertThat(model.getPaths().keySet()).allMatch(name -> name.startsWith("/qqq/v1/sql-workbench/"));
|
||||
assertThat(extension.getSpecs()).hasSize(21);
|
||||
assertThat(extension.getEffectivePathPrefix()).isEqualTo("/sql-workbench");
|
||||
|
||||
+26
-2
@@ -98,7 +98,29 @@ class SqlWorkbenchEndpointSpecTest extends BaseSqlWorkbenchTest
|
||||
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()));
|
||||
HttpResponse<String> response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if(response.statusCode() >= 400)
|
||||
{
|
||||
assertErrorBody(response);
|
||||
}
|
||||
return (response);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTTP errors retain their message and include the standard diagnostic fields.
|
||||
*******************************************************************************/
|
||||
private static void assertErrorBody(HttpResponse<String> response) throws Exception
|
||||
{
|
||||
Map<String, Object> body = json(response);
|
||||
assertThat(body).containsKeys("error", "code", "message", "traceId");
|
||||
for(String key : List.of("error", "code", "message", "traceId"))
|
||||
{
|
||||
assertThat(body.get(key)).as(key).isInstanceOf(String.class);
|
||||
assertThat((String) body.get(key)).as(key).isNotBlank();
|
||||
}
|
||||
assertThat(body.get("message")).isEqualTo(body.get("error"));
|
||||
}
|
||||
|
||||
|
||||
@@ -236,7 +258,9 @@ class SqlWorkbenchEndpointSpecTest extends BaseSqlWorkbenchTest
|
||||
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);
|
||||
HttpResponse<String> denied = client.send(unauthenticated, HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(denied.statusCode()).as(method + " authentication").isEqualTo(401);
|
||||
assertErrorBody(denied);
|
||||
}
|
||||
assertThat(json(send("GET", "/history", READER, null, null)).get("executions")).asList().isEmpty();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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