Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6e9ffec9
|
||
|
|
4f1e2305e1 | ||
|
|
c2e754b146
|
||
|
|
731225813d | ||
|
|
ec607016bd
|
||
|
|
5342c04a2a
|
||
|
|
c43c135ca4
|
||
|
|
f67853d0e4
|
||
|
|
1ac98b25cb
|
||
|
|
24ce85f187
|
||
|
|
52d2d6bc87
|
||
|
|
ebef56f34b
|
||
|
|
c497e7ece8
|
||
|
|
b8d33460bf
|
No files matched your search
+68
-11
@@ -1,14 +1,71 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /.*/
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
@@ -1,23 +0,0 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
@@ -1,3 +0,0 @@
|
||||
profile: repository-policy
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
@@ -0,0 +1,70 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget. Design: `docs/DESIGN.md`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
|
||||
./mvnw test # Unit tests (H2 MODE=MySQL, SQLite, Javalin over HTTP)
|
||||
./mvnw test -Dtest=ClassName
|
||||
CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo gate (70% instructions, 90% classes)
|
||||
./mvnw verify -Dpostgres.test.image=postgres:17-alpine
|
||||
```
|
||||
|
||||
How tests are split:
|
||||
- **Integration tests** (`*IntegrationTest`) run under failsafe and use `@ExtendWith(RequiresDockerCondition.class)`. Without Docker they are skipped locally; with `CI=true` the build fails instead.
|
||||
- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets access, `writer-*` gets access plus write plus share, anything else gets nothing.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point:** `SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkbenchQBitConfig>`.
|
||||
- Component producers are found in the `metadata` package only (`getPackageNameForFindingMetaDataProducers`), because `api` needs the optional Javalin dependency.
|
||||
- `postProduceActions` builds `SqlWorkbenchRuntime` and calls `api.SqlWorkbenchRoutes.register` when Javalin is on the classpath.
|
||||
|
||||
**Packages:**
|
||||
- `sql/`: `SqlScanner` (dialect-aware lexer), `SqlClassifier` (split, then classify as READ, EXPLAIN_ANALYZE, WRITE, DDL, or FORBIDDEN), `NamedParameterSql` (`:name` → `?`). Pure Java.
|
||||
- `engine/`:
|
||||
- `SqlExecutionEngine` handles read-only runs, the write preview/commit protocol, and EXPLAIN.
|
||||
- `SessionGuard` sets up the per-vendor read-only transaction and timeouts, and always restores the connection (or aborts it).
|
||||
- `WorkbenchBackends` resolves targets and derives backends for dedicated credentials.
|
||||
- `ResultCollector` converts types and applies the row/byte caps.
|
||||
- `ResultCache`, `RunningStatements`, and `WritePreviewTokens` are the runtime state.
|
||||
- `ExecutionAuditor` writes audits through QQQ actions as `QSystemUserSession`.
|
||||
- `explain/`: `PlanParser` (Postgres JSON, MySQL JSON/text, SQLite rows, text) and `PlanNode`.
|
||||
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
|
||||
- `notebook/`: `NotebookService` (CRUD, visibility, versions in one transaction, snapshots).
|
||||
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
|
||||
- `metadata/`: the 5 tables, the notebook PVS, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
|
||||
|
||||
**Invariants (do not break):**
|
||||
- Never log SQL text, parameters, or results. Only the audit table holds them.
|
||||
- Audit `start()` runs before any SQL. If it fails, the SQL does not run.
|
||||
- Audit updates are deferred until the session's connection is released (`Deferred` in the engine), so the engine never needs two pooled connections at once.
|
||||
- Engine-side session settings must be transaction-scoped (`SET LOCAL`) or reverted in `SessionGuard.close`.
|
||||
- SQL errors are results (`status` FAILED, TIMED_OUT, or CANCELLED). Exceptions are only for 400, 401, 403, and 404.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 3-space indentation, opening braces on the next line, `return (x);`.
|
||||
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
|
||||
- Fluent setters (`withX()`).
|
||||
- Wrapper types (`Integer`, `Boolean`) in config and entities.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional).
|
||||
- Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
|
||||
|
||||
## [1.0.0-RC.1] - 2026-10-11
|
||||
|
||||
First release candidate of 1.0, published to Maven Central from `release/1.0`. It contains every change listed under 1.0.0 below. Requires QQQ 4.1 (built against 4.1.0-RC.2) and a Next UI release that renders the `SQL_WORKBENCH` widget (`v1.0.0-RC.13` or later).
|
||||
|
||||
## [1.0.0] - Unreleased
|
||||
|
||||
First release. Requires QQQ 4.1 (built against 4.1.0-RC.2).
|
||||
|
||||
### Added
|
||||
- `SqlWorkbenchQBitProducer` / `SqlWorkbenchQBitConfig`: a standard QQQ 4.1 QBit, published as `com.kingsrook.qbits:qbit-sql-workbench`.
|
||||
- Tables:
|
||||
- `sqlWorkbenchNotebook`, `sqlWorkbenchCell`, `sqlWorkbenchNotebookVersion`, `sqlWorkbenchResultSnapshot`.
|
||||
- `sqlWorkbenchExecution`: the audit table, read-only for users.
|
||||
- The `SQL_WORKBENCH` widget and the `sqlWorkbench` app. There are no processes.
|
||||
- Execution engine:
|
||||
- Read-only by default: a database `READ ONLY` transaction plus a statement classifier.
|
||||
- Gated write mode: preview with a rolled-back dry run, single-use confirmation tokens, a row guard, a hard cap, and a drift check.
|
||||
- Per-vendor session guard with clean connection return.
|
||||
- Statement timeouts, row and byte caps, cancellation, a per-user concurrency limit.
|
||||
- Dedicated read-only and write credentials per backend, plus use of the backend's read-only replica.
|
||||
- EXPLAIN / EXPLAIN ANALYZE (always rolled back) normalized into a plan tree for Postgres, MySQL, SQLite, and H2.
|
||||
- Schema introspection for autocomplete, with QQQ table and field to SQL name mapping.
|
||||
- Notebooks:
|
||||
- Personal notebooks and a shared library.
|
||||
- Named parameters.
|
||||
- Version history with retention.
|
||||
- Pinned result snapshots.
|
||||
- REST API under `/qqq/v1/sql-workbench`, with CSV/XLSX/JSON export.
|
||||
- PostgreSQL DDL in `docs/ddl/postgres.sql`.
|
||||
|
||||
### Security
|
||||
- The qbit's tables need a separate admin permission (`adminPermissionBaseName`, default `sqlWorkbenchAdmin` => `sqlWorkbenchAdmin.hasAccess`). Ordinary workbench users have no generic table API access to notebooks, snapshots or the audit; the workbench endpoints check ownership and act as the system user.
|
||||
- Client-chosen `executionUuid`s are single-use (409 on reuse) and case-insensitive.
|
||||
- On MySQL, executable comments (`/*! */`, `/*M! */`) and optimizer hints (`/*+ */`) are refused.
|
||||
- CSV exports neutralize formula injection; connection errors are generic to the caller.
|
||||
- Production backends should configure `readOnlyCredentials` (see README, Safety model).
|
||||
@@ -0,0 +1,70 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit SQL Workbench**: a QQQ QBit that provides a super-user SQL notebook. Sessions are read-only by default, write mode is gated, and everything is audited. It works over any RDBMS backend of the instance. The UI is the Next UI `SQL_WORKBENCH` widget. Design: `docs/DESIGN.md`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
|
||||
./mvnw test # Unit tests (H2 MODE=MySQL, SQLite, Javalin over HTTP)
|
||||
./mvnw test -Dtest=ClassName
|
||||
CI=true ./mvnw verify # Unit + Postgres integration tests (Docker) + JaCoCo gate (70% instructions, 90% classes)
|
||||
./mvnw verify -Dpostgres.test.image=postgres:17-alpine
|
||||
```
|
||||
|
||||
How tests are split:
|
||||
- **Integration tests** (`*IntegrationTest`) run under failsafe and use `@ExtendWith(RequiresDockerCondition.class)`. Without Docker they are skipped locally; with `CI=true` the build fails instead.
|
||||
- **Unit tests** extend `BaseSqlWorkbenchTest`. It sets up an H2 backend `h2` with the snake_case DDL from `src/test/resources/sql-workbench-h2.sql`, MOCK auth, and a session customizer. The session id prefix picks the permissions: `reader-*` gets access, `writer-*` gets access plus write plus share, anything else gets nothing.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point:** `SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkbenchQBitConfig>`.
|
||||
- Component producers are found in the `metadata` package only (`getPackageNameForFindingMetaDataProducers`), because `api` needs the optional Javalin dependency.
|
||||
- `postProduceActions` builds `SqlWorkbenchRuntime` and calls `api.SqlWorkbenchRoutes.register` when Javalin is on the classpath.
|
||||
|
||||
**Packages:**
|
||||
- `sql/`: `SqlScanner` (dialect-aware lexer), `SqlClassifier` (split, then classify as READ, EXPLAIN_ANALYZE, WRITE, DDL, or FORBIDDEN), `NamedParameterSql` (`:name` → `?`). Pure Java.
|
||||
- `engine/`:
|
||||
- `SqlExecutionEngine` handles read-only runs, the write preview/commit protocol, and EXPLAIN.
|
||||
- `SessionGuard` sets up the per-vendor read-only transaction and timeouts, and always restores the connection (or aborts it).
|
||||
- `WorkbenchBackends` resolves targets and derives backends for dedicated credentials.
|
||||
- `ResultCollector` converts types and applies the row/byte caps.
|
||||
- `ResultCache`, `RunningStatements`, and `WritePreviewTokens` are the runtime state.
|
||||
- `ExecutionAuditor` writes audits through QQQ actions as `QSystemUserSession`.
|
||||
- `explain/`: `PlanParser` (Postgres JSON, MySQL JSON/text, SQLite rows, text) and `PlanNode`.
|
||||
- `schema/`: `SchemaIntrospector` (JDBC `DatabaseMetaData` plus the QQQ table and field to SQL name mapping), cached in the runtime.
|
||||
- `notebook/`: `NotebookService` (CRUD, visibility, versions in one transaction, snapshots).
|
||||
- `api/`: `SqlWorkbenchApi` (transport-agnostic, permission checks), `SqlWorkbenchRouteProvider` (Javalin 7 routes under `/qqq/v1/sql-workbench`), `ResultExporter` (CSV/JSON/XLSX via fastexcel), and `SqlWorkbenchRoutes`.
|
||||
- `metadata/`: the 5 tables, the notebook PVS, the `SQL_WORKBENCH` widget and its renderer, and the `sqlWorkbench` app.
|
||||
|
||||
**Invariants (do not break):**
|
||||
- Never log SQL text, parameters, or results. Only the audit table holds them.
|
||||
- Audit `start()` runs before any SQL. If it fails, the SQL does not run.
|
||||
- Audit updates are deferred until the session's connection is released (`Deferred` in the engine), so the engine never needs two pooled connections at once.
|
||||
- Engine-side session settings must be transaction-scoped (`SET LOCAL`) or reverted in `SessionGuard.close`.
|
||||
- SQL errors are results (`status` FAILED, TIMED_OUT, or CANCELLED). Exceptions are only for 400, 401, 403, and 404.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 3-space indentation, opening braces on the next line, `return (x);`.
|
||||
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
|
||||
- Fluent setters (`withX()`).
|
||||
- Wrapper types (`Integer`, `Boolean`) in config and entities.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional).
|
||||
- Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
@@ -15,7 +15,7 @@ The full design (architecture, data model, security model, limits, API contract,
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21 and QQQ 4.1 (built against `4.1.0-RC.1`).
|
||||
- Java 21 and QQQ 4.1 (built against `4.1.0-RC.2`).
|
||||
- `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.
|
||||
|
||||
@@ -74,11 +74,14 @@ Everything is denied by default.
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `sqlWorkbench.hasAccess` | The app, widget, tables, and every endpoint: read-only SQL, EXPLAIN, schema, notebooks, history |
|
||||
| `sqlWorkbench.hasAccess` | The app, widget, and every workbench endpoint: read-only SQL, EXPLAIN, schema, your notebooks and the shared library, your history |
|
||||
| `sqlWorkbench.write` | Write mode (preview and commit) and EXPLAIN ANALYZE |
|
||||
| `sqlWorkbench.share` | Publishing your notebooks to the shared library |
|
||||
| `sqlWorkbenchAdmin.hasAccess` | The qbit's five tables in the generic QQQ table UI / API (`/qqq/v1/table/...`): **every** user's notebooks (personal ones included), cells, versions, pinned result snapshots (real query data), and the full execution audit |
|
||||
|
||||
The names are configurable (`accessPermissionBaseName`, `writePermissionName`, `sharePermissionName`).
|
||||
The names are configurable (`accessPermissionBaseName`, `writePermissionName`, `sharePermissionName`, `adminPermissionBaseName`).
|
||||
|
||||
Grant `sqlWorkbenchAdmin.hasAccess` only to administrators or auditors. Ordinary workbench users do not need it: the workbench endpoints check ownership, visibility, and the share permission themselves, then read and write the tables as the system user. Without the admin permission a user cannot reach another user's personal notebook or audit row through the generic table API. Cells, versions, snapshots, and the audit are read-only even for admins (insert, edit, and delete are disabled; only the qbit writes them).
|
||||
|
||||
`write` and `share` are plain session permissions. QQQ 4.1 does not list them as available permissions, so grant them where you grant roles, for example in an authentication customizer.
|
||||
|
||||
@@ -86,6 +89,8 @@ The names are configurable (`accessPermissionBaseName`, `writePermissionName`, `
|
||||
|
||||
## Safety model
|
||||
|
||||
> **Production: configure `readOnlyCredentials` for every target backend.** Use a dedicated database role with `SELECT` grants only: no write privileges, not a superuser, no `pg_signal_backend` (or MySQL `PROCESS` / `SUPER` / `CONNECTION_ADMIN`). Without it, read-only mode connects as the application's own role and relies on the classifier and the session's `READ ONLY` transaction alone. On H2 / generic backends there is no enforced read-only transaction, so the classifier is the only control. On Postgres a `READ ONLY` transaction still lets the application role call side-effecting functions such as `pg_terminate_backend()` or `pg_cancel_backend()`. `/capabilities` reports `readOnlyCredentials: false` / `readOnlyEnforced: false` per backend so you can check.
|
||||
|
||||
**Read-only sessions:**
|
||||
- Postgres: `setReadOnly(true)`, `SET TRANSACTION READ ONLY`, `SET LOCAL statement_timeout` and `lock_timeout`.
|
||||
- MySQL: `setReadOnly(true)`, which sets the session transaction characteristic; it is reverted afterwards.
|
||||
@@ -100,6 +105,7 @@ The names are configurable (`accessPermissionBaseName`, `writePermissionName`, `
|
||||
- It is conservative. Anything that isn't recognized as a read is treated as a write.
|
||||
- A "read" that mentions a mutating keyword is also a write: data-modifying CTEs, `SELECT ... INTO`, `FOR UPDATE`, `nextval()`.
|
||||
- Transaction and session control is refused in every mode: `BEGIN`, `COMMIT`, `SET`, `RESET`, `USE`, `PRAGMA x=y`, `ATTACH`, `DO`, and others.
|
||||
- On MySQL / MariaDB, comments the server executes are refused in every mode: `/*! ... */`, `/*M! ... */` (version-conditional SQL), and `/*+ ... */` (optimizer hints). `--` only starts a comment when whitespace follows, as in MySQL.
|
||||
|
||||
**Clean connections:**
|
||||
- Every session ends with a rollback (or a commit for a confirmed write) and restores autocommit and read-only.
|
||||
@@ -140,6 +146,29 @@ Base path: `/qqq/v1/sql-workbench`. The contract is in [docs/DESIGN.md §5](docs
|
||||
|
||||
POST and PUT bodies must be `application/json`.
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
A checklist for adding the workbench to a QQQ application. Each row points to the section above that has the details.
|
||||
|
||||
| Item | What to do |
|
||||
|---|---|
|
||||
| Coordinates | `com.kingsrook.qbits:qbit-sql-workbench`. Current release candidate: `1.0.0-RC.1` (Maven Central, published from `release/1.0`). |
|
||||
| QQQ version | QQQ 4.1, `4.1.0-RC.2` or later. Java 21. |
|
||||
| Dependencies | `qqq-backend-module-rdbms` comes in transitively. Add `qqq-middleware-javalin` yourself for the REST routes; it is optional in this qbit's POM, and without it no routes are registered. |
|
||||
| Frontend | A Next UI (`qqq-frontend-next`) release that renders the `SQL_WORKBENCH` widget: `v1.0.0-RC.13` or later. Other frontends show the app and tables but not the notebook. |
|
||||
| Registration | `new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance)` after your backends are in the instance. The producer registers its own QBit metadata; don't add it by hand. To nest the app (for example under Admin), re-parent `SqlWorkbenchQBitProducer.APP_NAME` after producing. |
|
||||
| Storage | Create the five tables on `backendName` before first use ([Storage](#storage)). PostgreSQL DDL ships in [docs/ddl/postgres.sql](docs/ddl/postgres.sql) and drops into a Liquibase formatted-SQL changeset; translate it for other databases. Match the snake_case names with a `tableMetaDataCustomizer`. |
|
||||
| Targets | Every RDBMS backend in the instance is a target unless you narrow it with `allowedBackendNames` or `excludedBackendNames`. |
|
||||
| Secrets | In production, configure `readOnlyCredentials` for each target backend with a SELECT-only database role, and optionally `writeCredentials`. Pass them as `${env.X}` references (the example uses `SQLWB_RO_USER` and `SQLWB_RO_PASSWORD`; the names are yours). The qbit reads no other environment variables. |
|
||||
| Permissions | `sqlWorkbench.hasAccess` (use), `sqlWorkbench.write` (write mode), `sqlWorkbench.share` (shared library), `sqlWorkbenchAdmin.hasAccess` (raw table access to every user's notebooks and the full audit; administrators and auditors only). QQQ 4.1 does not list `write` and `share` as available permissions, so grant them where you grant roles. See [Permissions](#permissions). |
|
||||
| Turning writes off | `withWriteModeEnabled(false)` disables write mode for everyone. |
|
||||
| Known limitations | Raw SQL bypasses QQQ security, audits, listeners and automations ([Safety model](#safety-model)). H2 and generic backends have no enforced read-only transaction. Only PostgreSQL DDL ships. The qbit produces no processes. |
|
||||
| Upgrading | 1.0 is the first release; there is nothing to migrate. |
|
||||
|
||||
Nothing in the qbit is specific to one host application.
|
||||
|
||||
See also the [QQQ adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) for the full per-feature list (QQQ upgrade, Next dashboard, QBits, migrations, permissions).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
~ contact@kingsrook.com
|
||||
~ https://github.com/Kingsrook/
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
|
||||
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
|
||||
<!--
|
||||
See reference at https://checkstyle.sourceforge.io/checks.html
|
||||
-->
|
||||
<module name = "Checker">
|
||||
<property name="charset" value="UTF-8"/>
|
||||
|
||||
<property name="severity" value="warning"/>
|
||||
|
||||
<property name="fileExtensions" value="java, properties, xml"/>
|
||||
<!-- Checks for whitespace -->
|
||||
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
|
||||
<!--
|
||||
<module name="LineLength">
|
||||
<property name="max" value="100"/>
|
||||
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
|
||||
</module>
|
||||
-->
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="SuppressWarningsHolder"/>
|
||||
<module name="OuterTypeFilename"/>
|
||||
<module name="IllegalTokenText">
|
||||
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
|
||||
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
|
||||
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||
</module>
|
||||
<module name="AvoidEscapedUnicodeCharacters">
|
||||
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||
<property name="allowByTailComment" value="true"/>
|
||||
<property name="allowNonPrintableEscapes" value="true"/>
|
||||
</module>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="OneTopLevelClass"/>
|
||||
<module name="NoLineWrap"/>
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="TEXT"/>
|
||||
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
|
||||
</module>
|
||||
<module name="NeedBraces"/>
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="option" value="alone"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true"/>
|
||||
<property name="allowEmptyMethods" value="true"/>
|
||||
<property name="allowEmptyTypes" value="true"/>
|
||||
<property name="allowEmptyLoops" value="true"/>
|
||||
<message key="ws.notFollowed"
|
||||
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||
<message key="ws.notPreceded"
|
||||
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
-->
|
||||
<module name="OneStatementPerLine"/>
|
||||
<module name="MultipleVariableDeclarations"/>
|
||||
<module name="ArrayTypeStyle"/>
|
||||
<module name="MissingSwitchDefault"/>
|
||||
<module name="FallThrough"/>
|
||||
<module name="UpperEll"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapDot"/>
|
||||
<property name="tokens" value="DOT"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapComma"/>
|
||||
<property name="tokens" value="COMMA"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||
<property name="tokens" value="ELLIPSIS"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||
<property name="tokens" value="METHOD_REF"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="PackageName">
|
||||
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<message key="name.invalidPattern"
|
||||
value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MemberName">
|
||||
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="CatchParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LocalVariableName">
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="InterfaceTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="NoFinalizer"/>
|
||||
<module name="GenericWhitespace">
|
||||
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="Indentation">
|
||||
<property name="basicOffset" value="3"/>
|
||||
<property name="braceAdjustment" value="0"/>
|
||||
<property name="caseIndent" value="3"/>
|
||||
<property name="throwsIndent" value="6"/>
|
||||
<property name="lineWrappingIndentation" value="3"/>
|
||||
<property name="arrayInitIndent" value="6"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="AbbreviationAsWordInName">
|
||||
<property name="ignoreFinal" value="false"/>
|
||||
<property name="allowedAbbreviationLength" value="1"/>
|
||||
</module>
|
||||
<module name="OverloadMethodsDeclarationOrder"/>
|
||||
<module name="VariableDeclarationUsageDistance"/>
|
||||
<module name="CustomImportOrder">
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="true"/>
|
||||
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||
</module>
|
||||
-->
|
||||
<module name="CustomImportOrder">
|
||||
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
|
||||
<property name="specialImportsRegExp" value="^javax\."/>
|
||||
<property name="standardPackageRegExp" value="^java\."/>
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="false"/>
|
||||
</module>
|
||||
<module name="MethodParamPad"/>
|
||||
<module name="NoWhitespaceBefore">
|
||||
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
|
||||
<property name="allowLineBreaks" value="true"/>
|
||||
</module>
|
||||
<module name="ParenPad"/>
|
||||
<module name="OperatorWrap">
|
||||
<property name="option" value="NL"/>
|
||||
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationMostCases"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationVariables"/>
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||
</module>
|
||||
<module name="JavadocMethod">
|
||||
<property name="allowMissingParamTags" value="true"/>
|
||||
<property name="allowMissingReturnTag" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
</module>
|
||||
<module name="MissingJavadocMethod">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MissingJavadocType">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MethodName">
|
||||
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="exceptionVariableName" value="expected"/>
|
||||
</module>
|
||||
<module name="CommentsIndentation"/>
|
||||
|
||||
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
|
||||
<module name="MissingOverride"/>
|
||||
|
||||
</module>
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
+11
-6
@@ -1,6 +1,6 @@
|
||||
# SQL Workbench QBit: design
|
||||
|
||||
Status: accepted for 1.0 (owner decisions 2026-10-11). Built against QQQ 4.1.0-RC.1 and Next UI 1.0.0-RC.11.
|
||||
Status: accepted for 1.0 (owner decisions 2026-10-11). Built against QQQ 4.1.0-RC.2 and Next UI 1.0.0-RC.11.
|
||||
|
||||
SQL Workbench is a super-user SQL notebook for any QQQ application. Users write SQL (and markdown) in notebook cells, run it against any RDBMS backend of the instance, and see typed, paged, exportable results, quick charts, and visual EXPLAIN plans. Sessions are **read-only by default**. A separate permission unlocks a **gated write mode** with per-run confirmation, a dry run, and an affected-row guard. **Every execution is audited.**
|
||||
|
||||
@@ -174,7 +174,7 @@ The audit table has its insert, edit, and delete capabilities disabled in the QQ
|
||||
1. **Authorize.** The session must have `sqlWorkbench.hasAccess` (403 otherwise). Write and EXPLAIN ANALYZE also need `sqlWorkbench.write` and `config.writeModeEnabled`.
|
||||
2. **Resolve the target.** The backend must be an `RDBMSBackendMetaData` in the instance and allowed by `allowedBackendNames` / `excludedBackendNames` (404 otherwise). The dialect comes from the vendor.
|
||||
3. **Validate.** Check the SQL length (`maxSqlLength`, default 100 KB), then split and classify with `SqlClassifier`. Rules:
|
||||
- `FORBIDDEN` statements are rejected in every mode.
|
||||
- `FORBIDDEN` statements are rejected in every mode. On MySQL this includes any statement with an executable comment (`/*! ... */`, `/*M! ... */`) or optimizer hint (`/*+ ... */`): MySQL runs their contents, so the scanner keeps them as tokens instead of skipping them, and fails closed. MySQL `--` comments need trailing whitespace (`1--1` is arithmetic).
|
||||
- Read-only mode allows exactly **one** statement, of kind `READ`.
|
||||
- Write mode allows up to `maxStatementsPerRun` (default 50).
|
||||
- Every `:param` must have a value. Values are bound through `PreparedStatement`, never spliced into the SQL.
|
||||
@@ -202,6 +202,8 @@ Per-vendor setup (all of it transaction-scoped, or reverted in `finally`):
|
||||
|
||||
There is no session-level `SET` leakage: user SQL can never run `SET` / `RESET` / `USE` / `PRAGMA x=y` / transaction control (all `FORBIDDEN`), and the engine's own settings are `SET LOCAL` or reverted. If any cleanup step fails, the engine calls `connection.abort()` (falling back to `close()`) so a dirty connection is never reused, and logs a warning (without the SQL).
|
||||
|
||||
> **Production: configure `readOnlyCredentials` for every target backend.** Use a dedicated database role with `SELECT` grants only: no write privileges, not a superuser, no `pg_signal_backend` (or MySQL `PROCESS` / `SUPER` / `CONNECTION_ADMIN`). Without it, read-only mode connects as the application's own role and relies on the classifier and the session's `READ ONLY` transaction alone. On H2 / generic backends there is no enforced read-only transaction, so the classifier is the only control. On Postgres a `READ ONLY` transaction still lets the application role call side-effecting functions such as `pg_terminate_backend()` or `pg_cancel_backend()`. `/capabilities` reports `readOnlyCredentials: false` / `readOnlyEnforced: false` per backend so you can check.
|
||||
|
||||
> **Defense in depth.** The READ ONLY transaction is the primary control on Postgres, MySQL, and SQLite. On H2 the classifier is the only control, so production should use Postgres or MySQL with `readOnlyCredentials` (a role with `SELECT` grants only). A READ ONLY transaction does not stop side-effecting *functions* that are not writes, such as `pg_terminate_backend()` or `pg_sleep()`. Only a least-privilege role does. That is why `readOnlyCredentials` is recommended for production.
|
||||
|
||||
### 4.3 Write mode
|
||||
@@ -274,7 +276,7 @@ The response is `{executionUuid, dialect, analyze, plan: PlanNode, raw: string}`
|
||||
|
||||
SQL errors are results, not HTTP errors: `/execute` and `/explain` return 200 with `status` FAILED, TIMED_OUT, or CANCELLED plus `error` and `sqlState`.
|
||||
|
||||
All endpoints need an authenticated QQQ session (cookie or Authorization header, via `ExecutorSessionUtils.setupSession`) and `sqlWorkbench.hasAccess`. Errors are `{"error": "..."}` with 400, 401, 403, 404, or 500, through `QJavalinUtils.handleException`. Bodies are JSON. Request and response bodies are never logged.
|
||||
All endpoints need an authenticated QQQ session (cookie or Authorization header, via `ExecutorSessionUtils.setupSession`) and `sqlWorkbench.hasAccess`. Errors are `{"error": "..."}` with 400, 401, 403, 404, 409 (a re-used `executionUuid`), or 500, through `QJavalinUtils.handleException`. Execution UUIDs are case-insensitive on every path (`/execute`, `/executions/{uuid}/rows`, `/export`, `/cancel`, snapshots); the server stores and returns them lower-case. Bodies are JSON. Request and response bodies are never logged.
|
||||
|
||||
```yaml
|
||||
GET /capabilities
|
||||
@@ -286,7 +288,9 @@ GET /capabilities
|
||||
GET /schema/{backendName}?refresh=bool -> Schema (see 4.6)
|
||||
POST /execute
|
||||
body: { backendName, sql, params?: {name: value}, mode?: READ_ONLY|WRITE, notebookId?, cellKey?,
|
||||
pageSize?, confirmToken?, acknowledgeRowGuard? }
|
||||
pageSize?, confirmToken?, acknowledgeRowGuard?,
|
||||
executionUuid? } # optional client-chosen UUID, so the client can cancel before the response;
|
||||
# single-use: an id that is running or already audited gets 409
|
||||
200 (READ_ONLY / commit): { executionUuid, status, mode, statements: [{index, kind, leadingKeyword, affectedRows?}],
|
||||
columns: [Column], rows: [[...]], rowCount, hasMore, truncated, truncatedReason?, durationMs,
|
||||
rowsAffected? }
|
||||
@@ -328,7 +332,7 @@ Visibility rules:
|
||||
|
||||
| Concern | Control |
|
||||
|---|---|
|
||||
| Who | Deny by default. Every endpoint needs `sqlWorkbench.hasAccess` (the app, widget, and table permission base name `sqlWorkbench`, at `HAS_ACCESS_PERMISSION`). Write mode and EXPLAIN ANALYZE need `sqlWorkbench.write`. Publishing to the library needs `sqlWorkbench.share`. The names are configurable. |
|
||||
| Who | Deny by default. Every endpoint needs `sqlWorkbench.hasAccess` (the app and widget permission base name `sqlWorkbench`, at `HAS_ACCESS_PERMISSION`). Write mode and EXPLAIN ANALYZE need `sqlWorkbench.write`. Publishing to the library needs `sqlWorkbench.share`. The qbit's tables use a separate base name, `sqlWorkbenchAdmin` (`sqlWorkbenchAdmin.hasAccess`), so only administrators reach them through the generic QQQ table UI / API. The names are configurable. |
|
||||
| What can run | Read-only transactions by default (§4.2); classifier as defense in depth; `FORBIDDEN` statements in every mode; one statement per read-only run. |
|
||||
| Writes | Separate permission, two-step preview then commit with a server-side single-use token bound to the user, SQL, and backend, a dry run with rollback, a row guard with acknowledgement, a hard cap, a drift check, and a statement timeout. |
|
||||
| Credentials | Never returned or logged. Backend metadata goes out only as `{name, label, vendor, dialect}`. `readOnlyCredentials` support `${env.X}` / `${prop.X}` interpolation (`QMetaDataVariableInterpreter`), and the config's `toString` redacts them. |
|
||||
@@ -336,7 +340,7 @@ Visibility rules:
|
||||
| Session and CSRF | QQQ middleware session handling. The cookie session is `HttpOnly`; there is no extra CSRF token, matching the rest of `/qqq/v1` (the JSON-only POSTs need `Content-Type: application/json`, which a cross-site form cannot send without a CORS preflight). The route provider rejects non-JSON POST bodies. |
|
||||
| Isolation between users | Result cache, cancellation, history, and write tokens are keyed by the user's `idReference`. Another user's execution UUID returns 404. |
|
||||
| QQQ semantics | **Raw SQL bypasses QQQ**: no record security locks, table/field permissions, hidden fields, audit of record changes, or customizers/automations. Rows changed in write mode do **not** trigger QQQ audits, record-change listeners, ESB events, or Quick Search indexing. This is documented in the UI (a banner in write mode) and in the README. Use QQQ processes for business changes. |
|
||||
| Personal vs shared | "Personal" is a UX scope, not a confidentiality boundary between super-users. Notebook tables are visible in the QQQ table UI to anyone with `sqlWorkbench.hasAccess`. |
|
||||
| Personal vs shared | A personal notebook is visible only to its owner. The five tables need `sqlWorkbenchAdmin.hasAccess`, which ordinary workbench users do not hold, so `/qqq/v1/table/...` cannot read or write other users' notebooks, cells, versions, pinned snapshots, or audit rows. `NotebookService` and the history endpoint check owner / visibility / share permission in code, then read and write as the system user (`SystemUser`), as `ExecutionAuditor` does for the audit. Cells, versions, snapshots, and the audit have insert / edit / delete disabled; only the qbit writes them. Holders of the admin permission see every user's notebooks and the full audit. |
|
||||
|
||||
---
|
||||
|
||||
@@ -356,6 +360,7 @@ Visibility rules:
|
||||
| `accessPermissionBaseName` | `sqlWorkbench` | `<base>.hasAccess` |
|
||||
| `writePermissionName` | `sqlWorkbench.write` | |
|
||||
| `sharePermissionName` | `sqlWorkbench.share` | |
|
||||
| `adminPermissionBaseName` | `sqlWorkbenchAdmin` | `<base>.hasAccess` guards the qbit's tables in the generic table UI / API; must differ from `accessPermissionBaseName` |
|
||||
| `statementTimeoutSeconds` | 30 | read-only and EXPLAIN |
|
||||
| `writeStatementTimeoutSeconds` | 60 | preview and commit |
|
||||
| `maxRows` | 10000 | rows kept per result |
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
-- SQL Workbench QBit (com.kingsrook.qbits:qbit-sql-workbench) - PostgreSQL DDL
|
||||
--
|
||||
-- Table and column names are the snake_case forms of the QQQ names, which is
|
||||
-- what a host gets when its tableMetaDataCustomizer applies
|
||||
-- QInstanceEnricher.inferBackendName to the table name and
|
||||
-- QInstanceEnricher.setInferredFieldBackendNames to the fields (see README,
|
||||
-- "Storage"). With a tableNamePrefix, prefix the table names the same way.
|
||||
--
|
||||
-- Liquibase: paste into a formatted-SQL changeset, e.g.
|
||||
-- -- liquibase formatted sql
|
||||
-- -- changeset you:NNNN-1
|
||||
-- Idempotent (IF NOT EXISTS), so it is safe on re-run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
owner_user_id VARCHAR(255) NOT NULL,
|
||||
owner_name VARCHAR(255),
|
||||
visibility VARCHAR(16) NOT NULL DEFAULT 'PERSONAL',
|
||||
default_backend_name VARCHAR(100),
|
||||
parameters_json TEXT,
|
||||
current_version INTEGER,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_notebook PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_owner ON sql_workbench_notebook (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_visibility ON sql_workbench_notebook (visibility);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_cell (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
cell_key VARCHAR(64) NOT NULL,
|
||||
sort_order INTEGER,
|
||||
cell_type VARCHAR(16) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
content TEXT,
|
||||
backend_name VARCHAR(100),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_cell PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_cell_key UNIQUE (notebook_id, cell_key),
|
||||
CONSTRAINT fk_sql_workbench_cell_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook_version (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
content_json TEXT,
|
||||
saved_by_user_id VARCHAR(255),
|
||||
saved_by_name VARCHAR(255),
|
||||
comment VARCHAR(255),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_notebook_version PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_notebook_version UNIQUE (notebook_id, version_number),
|
||||
CONSTRAINT fk_sql_workbench_notebook_version_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_result_snapshot (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
cell_key VARCHAR(64),
|
||||
execution_uuid VARCHAR(36),
|
||||
label VARCHAR(255),
|
||||
backend_name VARCHAR(100),
|
||||
sql_text TEXT,
|
||||
result_json TEXT,
|
||||
row_count INTEGER,
|
||||
truncated BOOLEAN,
|
||||
created_by_user_id VARCHAR(255),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_result_snapshot PRIMARY KEY (id),
|
||||
CONSTRAINT fk_sql_workbench_result_snapshot_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
-- Audit: append-only. Not foreign-keyed to notebooks, so deleting a
|
||||
-- notebook never deletes its audit trail.
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_execution (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
execution_uuid VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(255),
|
||||
user_name VARCHAR(255),
|
||||
backend_name VARCHAR(100),
|
||||
mode VARCHAR(24),
|
||||
status VARCHAR(16),
|
||||
sql_text TEXT,
|
||||
sql_hash VARCHAR(64),
|
||||
parameters_json TEXT,
|
||||
statement_count INTEGER,
|
||||
statement_kinds VARCHAR(255),
|
||||
notebook_id INTEGER,
|
||||
cell_key VARCHAR(64),
|
||||
start_time TIMESTAMP WITHOUT TIME ZONE,
|
||||
end_time TIMESTAMP WITHOUT TIME ZONE,
|
||||
duration_ms INTEGER,
|
||||
rows_returned INTEGER,
|
||||
rows_affected INTEGER,
|
||||
truncated BOOLEAN,
|
||||
error_message TEXT,
|
||||
client_ip VARCHAR(64),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_sql_workbench_execution PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_execution_uuid UNIQUE (execution_uuid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_execution_user ON sql_workbench_execution (user_id, id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_execution_start ON sql_workbench_execution (start_time);
|
||||
@@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
@@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
@@ -0,0 +1,289 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Kingsrook, LLC
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<postgres.test.image>postgres:16-alpine</postgres.test.image>
|
||||
<testcontainers.version>1.21.4</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-sql-workbench</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit SQL Workbench</name>
|
||||
<description>QBit providing a super-user SQL notebook (read-only by default, gated write mode, audited) over any QQQ RDBMS backend</description>
|
||||
<url>https://github.com/QRun-IO/qbit-sql-workbench</url>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>kingsrook</id>
|
||||
<name>Kingsrook Team</name>
|
||||
<email>contact@kingsrook.com</email>
|
||||
<organization>Kingsrook, LLC</organization>
|
||||
<organizationUrl>https://kingsrook.com</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/QRun-IO/qbit-sql-workbench.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-sql-workbench.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-sql-workbench</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Connection management and backend meta-data for the RDBMS targets. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- The REST routes (/qqq/v1/sql-workbench/*). Hosts that serve QQQ through -->
|
||||
<!-- Javalin already have it; others can still use the engine directly. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.2.220</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-sqlite</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.8.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Testcontainers for the Postgres integration tests -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>sql-workbench.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>sql-workbench.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- Exclude integration tests from unit test run -->
|
||||
<excludes>
|
||||
<exclude>**/*IntegrationTest.java</exclude>
|
||||
<exclude>**/*IT.java</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<!-- Run integration tests with failsafe -->
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<postgres.test.image>postgres:16-alpine</postgres.test.image>
|
||||
</systemPropertyVariables>
|
||||
<includes>
|
||||
<include>**/*IntegrationTest.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer, runtime services, Javalin 7 -->
|
||||
<!-- routes). Until qbit-build-parent 2.1.0 imports the released 4.1 BOM, -->
|
||||
<!-- this profile is active by default and imports qqq-bom-pom 4.1.0-RC.2 -->
|
||||
<!-- ahead of the parent's 4.0.0 BOM (same as qbit-quick-search, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.2</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>sonatype-snapshots</id>
|
||||
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dedicated database credentials the workbench uses for one target backend,
|
||||
** instead of the application's own (for example a role with SELECT grants
|
||||
** only, for read-only sessions). Defence in depth: a read-only transaction
|
||||
** does not stop side-effecting functions, a least-privilege role does.
|
||||
**
|
||||
** username and password may be ${env.X} / ${prop.X} references; they are
|
||||
** resolved only when a connection is opened. jdbcUrl is optional (default:
|
||||
** the backend's own URL). Never serialized; toString redacts.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCredentials implements Serializable
|
||||
{
|
||||
private String username;
|
||||
private String password;
|
||||
private String jdbcUrl;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Redacting toString.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("SqlWorkbenchCredentials{username=" + (username == null ? "null" : "***") + ", password=***, jdbcUrl=" + (jdbcUrl == null ? "default" : "custom") + "}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for username
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getUsername()
|
||||
{
|
||||
return (this.username);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for username
|
||||
*******************************************************************************/
|
||||
public void setUsername(String username)
|
||||
{
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for username
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCredentials withUsername(String username)
|
||||
{
|
||||
this.username = username;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for password
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getPassword()
|
||||
{
|
||||
return (this.password);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for password
|
||||
*******************************************************************************/
|
||||
public void setPassword(String password)
|
||||
{
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for password
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCredentials withPassword(String password)
|
||||
{
|
||||
this.password = password;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for jdbcUrl. Ignored by JSON: a URL can carry a password.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getJdbcUrl()
|
||||
{
|
||||
return (this.jdbcUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for jdbcUrl
|
||||
*******************************************************************************/
|
||||
public void setJdbcUrl(String jdbcUrl)
|
||||
{
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for jdbcUrl
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCredentials withJdbcUrl(String jdbcUrl)
|
||||
{
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchTableMetaDataHelper;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point of the SQL Workbench QBit, a QQQ 4.1 QBitMetaDataProducer.
|
||||
**
|
||||
** The framework's default produce() registers QBitMetaData
|
||||
** (com.kingsrook.qbits:sql-workbench), validates the config, runs the
|
||||
** component producers in the metadata package (five tables, notebook PVS,
|
||||
** SQL_WORKBENCH widget, sqlWorkbench app), applies the config's table
|
||||
** customizer and default backend, stamps sourceQBitName, then calls
|
||||
** {@link #postProduceActions}, which builds the runtime and registers the
|
||||
** /qqq/v1/sql-workbench Javalin routes.
|
||||
**
|
||||
** Host usage:
|
||||
** new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchQBitProducer implements QBitMetaDataProducer<SqlWorkbenchQBitConfig>
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SqlWorkbenchQBitProducer.class);
|
||||
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "sql-workbench";
|
||||
public static final String APP_NAME = SqlWorkbenchAppMetaDataProducer.APP_NAME;
|
||||
|
||||
private static final String JAVALIN_CLASS = "com.kingsrook.qqq.middleware.javalin.QJavalinMetaData";
|
||||
private static final String VERSION = loadVersion();
|
||||
|
||||
private SqlWorkbenchQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchQBitProducer withQBitConfig(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for the config.
|
||||
*******************************************************************************/
|
||||
public void setQBitConfig(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SqlWorkbenchQBitConfig getQBitConfig()
|
||||
{
|
||||
return (this.qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespace for this QBit instance; also the default tableNamePrefix.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (this.namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(getNamespace())
|
||||
.withConfig(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Component producers live in the metadata package only (the api package
|
||||
** needs qqq-middleware-javalin, which is optional).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPackageNameForFindingMetaDataProducers()
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.class.getPackageName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A producer found by package scanning has no config; skip it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce and add everything to the instance. Returns an empty output so a
|
||||
** host that also calls addSelfToInstance on the result adds nothing twice.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
if(qBitConfig == null)
|
||||
{
|
||||
throw (new QException("SqlWorkbenchQBit configuration validation failed: no config was supplied (call withQBitConfig)"));
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(namespace) && !StringUtils.hasContent(qBitConfig.getTableNamePrefix()))
|
||||
{
|
||||
qBitConfig.setTableNamePrefix(namespace);
|
||||
}
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException("SqlWorkbenchQBit configuration validation failed: " + String.join("; ", errors)));
|
||||
}
|
||||
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the runtime and register the REST routes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void postProduceActions(MetaDataProducerMultiOutput output, QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchRuntime previous = qBitConfig.getRuntime();
|
||||
if(previous != null)
|
||||
{
|
||||
previous.close();
|
||||
}
|
||||
qBitConfig.setRuntime(new SqlWorkbenchRuntime(qBitConfig));
|
||||
|
||||
boolean routes = false;
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchRoutes.register(qInstance);
|
||||
routes = true;
|
||||
}
|
||||
|
||||
LOG.info("SQL Workbench QBit produced", logPair("version", VERSION), logPair("backendName", qBitConfig.getBackendName()),
|
||||
logPair("routes", routes), logPair("writeModeEnabled", qBitConfig.getWriteModeEnabled()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static boolean isJavalinAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(JAVALIN_CLASS, false, SqlWorkbenchQBitProducer.class.getClassLoader());
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The artifact version, from the Maven-filtered sql-workbench.properties.
|
||||
*******************************************************************************/
|
||||
static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = SqlWorkbenchQBitProducer.class.getClassLoader().getResourceAsStream("sql-workbench.properties"))
|
||||
{
|
||||
if(inputStream != null)
|
||||
{
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(StringUtils.hasContent(version) && !version.contains("${"))
|
||||
{
|
||||
return (version);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Could not read sql-workbench.properties", e);
|
||||
}
|
||||
return ("unknown");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the artifact version.
|
||||
*******************************************************************************/
|
||||
public static String getVersion()
|
||||
{
|
||||
return (VERSION);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.RunningStatements;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WritePreviewTokens;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance-scoped live state of the SQL Workbench: result cache, running
|
||||
** statements, write-preview tokens, schema cache, and the derived backend
|
||||
** meta-data used for dedicated credentials. Held (transient) on the config;
|
||||
** resolved from the QContext's instance with {@link #get()}.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRuntime
|
||||
{
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
private final Clock clock;
|
||||
private final ResultCache resultCache;
|
||||
private final RunningStatements runningStatements = new RunningStatements();
|
||||
private final WritePreviewTokens writePreviewTokens;
|
||||
|
||||
private final Map<String, CachedSchema> schemaCache = new ConcurrentHashMap<>();
|
||||
private final Map<String, RDBMSBackendMetaData> derivedBackends = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A cached schema description.
|
||||
**
|
||||
** @param schema the response body
|
||||
** @param cachedAt when it was read
|
||||
*******************************************************************************/
|
||||
public record CachedSchema(Map<String, Object> schema, Instant cachedAt)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchRuntime(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this(config, Clock.systemUTC());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchRuntime(SqlWorkbenchQBitConfig config, Clock clock)
|
||||
{
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
this.resultCache = new ResultCache(config.getResultCacheMaxEntries(), config.getResultCacheTtlSeconds(), clock);
|
||||
this.writePreviewTokens = new WritePreviewTokens(config.getWriteConfirmTtlSeconds(), clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime of the SQL Workbench qbit in the QContext's instance.
|
||||
*******************************************************************************/
|
||||
public static SqlWorkbenchRuntime get() throws QNotFoundException
|
||||
{
|
||||
return (get(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime of the SQL Workbench qbit in an instance.
|
||||
*******************************************************************************/
|
||||
public static SqlWorkbenchRuntime get(QInstance qInstance) throws QNotFoundException
|
||||
{
|
||||
if(qInstance != null && qInstance.getQBits() != null)
|
||||
{
|
||||
for(QBitMetaData qBit : qInstance.getQBits().values())
|
||||
{
|
||||
if(qBit.getConfig() instanceof SqlWorkbenchQBitConfig config && config.getRuntime() != null)
|
||||
{
|
||||
return (config.getRuntime());
|
||||
}
|
||||
}
|
||||
}
|
||||
throw (new QNotFoundException("The SQL Workbench is not installed in this application."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached schema for a backend, if still fresh.
|
||||
*******************************************************************************/
|
||||
public CachedSchema getCachedSchema(String backendName)
|
||||
{
|
||||
CachedSchema cached = schemaCache.get(backendName);
|
||||
if(cached != null && cached.cachedAt().plusSeconds(config.getSchemaCacheTtlSeconds()).isAfter(clock.instant()))
|
||||
{
|
||||
return (cached);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void putCachedSchema(String backendName, Map<String, Object> schema)
|
||||
{
|
||||
schemaCache.put(backendName, new CachedSchema(schema, clock.instant()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release in-memory state (cache, tokens). Running statements finish on
|
||||
** their own.
|
||||
*******************************************************************************/
|
||||
public void close()
|
||||
{
|
||||
resultCache.clear();
|
||||
schemaCache.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for config
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchQBitConfig getConfig()
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clock
|
||||
*******************************************************************************/
|
||||
public Clock getClock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resultCache
|
||||
*******************************************************************************/
|
||||
public ResultCache getResultCache()
|
||||
{
|
||||
return (resultCache);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runningStatements
|
||||
*******************************************************************************/
|
||||
public RunningStatements getRunningStatements()
|
||||
{
|
||||
return (runningStatements);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writePreviewTokens
|
||||
*******************************************************************************/
|
||||
public WritePreviewTokens getWritePreviewTokens()
|
||||
{
|
||||
return (writePreviewTokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Derived backend meta-data (dedicated credentials), by derived name.
|
||||
*******************************************************************************/
|
||||
public Map<String, RDBMSBackendMetaData> getDerivedBackends()
|
||||
{
|
||||
return (derivedBackends);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs a block as the QQQ system user, in the current instance.
|
||||
**
|
||||
** The qbit's tables are guarded by the admin permission, which ordinary
|
||||
** workbench users do not hold. The workbench's own services (notebooks,
|
||||
** history, the audit) first check the caller - owner, visibility, share
|
||||
** permission - in code, and only then read or write those tables through
|
||||
** this. Never call it before those checks.
|
||||
*******************************************************************************/
|
||||
public final class SystemUser
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SystemUser()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a block as the system user.
|
||||
*******************************************************************************/
|
||||
public static void run(Block block) throws QException
|
||||
{
|
||||
call(() ->
|
||||
{
|
||||
block.run();
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a block as the system user and return its value.
|
||||
*******************************************************************************/
|
||||
public static <T> T call(Supplier<T> supplier) throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
Object[] result = new Object[1];
|
||||
QContext.<QException>withTemporaryContext(new CapturedContext(qInstance, new QSystemUserSession()), () -> result[0] = supplier.get());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
T value = (T) result[0];
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A block that may throw QException.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Block
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A value-returning block that may throw QException.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Supplier<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
T get() throws QException;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.CollectedResult;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
|
||||
import org.dhatim.fastexcel.Workbook;
|
||||
import org.dhatim.fastexcel.Worksheet;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes a cached result as CSV (RFC 4180, UTF-8 with BOM for Excel), JSON
|
||||
** (array of objects keyed by unique column labels) or XLSX (fastexcel,
|
||||
** which qqq-backend-core already ships).
|
||||
*******************************************************************************/
|
||||
public class ResultExporter
|
||||
{
|
||||
private static final int XLSX_MAX_CELL_CHARS = 32_767;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supported formats.
|
||||
*******************************************************************************/
|
||||
public enum Format
|
||||
{
|
||||
CSV("text/csv; charset=utf-8", "csv"),
|
||||
JSON("application/json", "json"),
|
||||
XLSX("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx");
|
||||
|
||||
private final String contentType;
|
||||
private final String extension;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Format(String contentType, String extension)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a format name (case-insensitive).
|
||||
*******************************************************************************/
|
||||
public static Format of(String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Format.valueOf(String.valueOf(name).toUpperCase(Locale.ROOT)));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("format must be csv, json or xlsx."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getContentType()
|
||||
{
|
||||
return (contentType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getExtension()
|
||||
{
|
||||
return (extension);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write the result in a format.
|
||||
*******************************************************************************/
|
||||
public static void write(CollectedResult result, Format format, OutputStream out) throws IOException
|
||||
{
|
||||
switch(format)
|
||||
{
|
||||
case CSV -> writeCsv(result, out);
|
||||
case JSON -> writeJson(result, out);
|
||||
case XLSX -> writeXlsx(result, out);
|
||||
default -> throw (new IllegalStateException("Unexpected format: " + format));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void writeCsv(CollectedResult result, OutputStream out) throws IOException
|
||||
{
|
||||
Writer writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
|
||||
writer.write('');
|
||||
List<String> header = result.columns().stream().map(ColumnInfo::label).toList();
|
||||
writer.write(csvLine(new ArrayList<>(header)));
|
||||
for(List<Object> row : result.rows())
|
||||
{
|
||||
writer.write(csvLine(row));
|
||||
}
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CSV formula injection: a spreadsheet runs a text cell that starts with
|
||||
** =, +, -, @, tab or CR as a formula. Prefix those with a quote so they
|
||||
** open as text. (Numbers are written as numbers and are left alone.)
|
||||
*******************************************************************************/
|
||||
static String neutralizeFormula(String text)
|
||||
{
|
||||
if(!text.isEmpty() && "=+-@\t\r".indexOf(text.charAt(0)) >= 0)
|
||||
{
|
||||
return ("'" + text);
|
||||
}
|
||||
return (text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String csvLine(List<?> values)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(int i = 0; i < values.size(); i++)
|
||||
{
|
||||
if(i > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
Object value = values.get(i);
|
||||
if(value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String text = value instanceof String s ? neutralizeFormula(s) : value instanceof Number || value instanceof Boolean ? value.toString() : JsonUtils.toJson(value);
|
||||
if(text.contains(",") || text.contains("\"") || text.contains("\n") || text.contains("\r"))
|
||||
{
|
||||
text = "\"" + text.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
sb.append(text);
|
||||
}
|
||||
return (sb.append("\r\n").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void writeJson(CollectedResult result, OutputStream out) throws IOException
|
||||
{
|
||||
List<String> keys = uniqueLabels(result.columns());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(List<Object> row : result.rows())
|
||||
{
|
||||
Map<String, Object> object = new LinkedHashMap<>();
|
||||
for(int i = 0; i < keys.size(); i++)
|
||||
{
|
||||
object.put(keys.get(i), row.get(i));
|
||||
}
|
||||
rows.add(object);
|
||||
}
|
||||
out.write(JsonUtils.toJson(rows).getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void writeXlsx(CollectedResult result, OutputStream out) throws IOException
|
||||
{
|
||||
Workbook workbook = new Workbook(out, "QQQ SQL Workbench", "1.0");
|
||||
Worksheet worksheet = workbook.newWorksheet("Result");
|
||||
List<ColumnInfo> columns = result.columns();
|
||||
for(int c = 0; c < columns.size(); c++)
|
||||
{
|
||||
worksheet.value(0, c, columns.get(c).label());
|
||||
worksheet.style(0, c).bold().set();
|
||||
}
|
||||
|
||||
for(int r = 0; r < result.rows().size(); r++)
|
||||
{
|
||||
List<Object> row = result.rows().get(r);
|
||||
for(int c = 0; c < row.size(); c++)
|
||||
{
|
||||
Object value = row.get(c);
|
||||
if(value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(value instanceof Number n && "number".equals(columns.get(c).kind()))
|
||||
{
|
||||
worksheet.value(r + 1, c, n instanceof BigDecimal bd ? bd : new BigDecimal(n.toString()));
|
||||
}
|
||||
else if(value instanceof Boolean b)
|
||||
{
|
||||
worksheet.value(r + 1, c, b);
|
||||
}
|
||||
else
|
||||
{
|
||||
String text = value instanceof String s ? s : JsonUtils.toJson(value);
|
||||
worksheet.value(r + 1, c, text.length() > XLSX_MAX_CELL_CHARS ? text.substring(0, XLSX_MAX_CELL_CHARS) : text);
|
||||
}
|
||||
}
|
||||
}
|
||||
workbook.finish();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Column labels made unique (a, a_2, a_3...), for JSON keys.
|
||||
*******************************************************************************/
|
||||
static List<String> uniqueLabels(List<ColumnInfo> columns)
|
||||
{
|
||||
Set<String> seen = new HashSet<>();
|
||||
List<String> labels = new ArrayList<>();
|
||||
for(ColumnInfo column : columns)
|
||||
{
|
||||
String label = column.label() == null ? column.name() : column.label();
|
||||
String key = label;
|
||||
for(int n = 2; !seen.add(key); n++)
|
||||
{
|
||||
key = label + "_" + n;
|
||||
}
|
||||
labels.add(key);
|
||||
}
|
||||
return (labels);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionRequest;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.SqlExecutionEngine;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qbits.sqlworkbench.schema.SchemaIntrospector;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SQL Workbench API, independent of HTTP: maps in, maps out, QQQ
|
||||
** exceptions for errors (400 / 401 / 403 / 404). Works on the QContext's
|
||||
** instance and session; every method first requires the access permission.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchApi
|
||||
{
|
||||
private final QInstance qInstance;
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
private final SqlExecutionEngine engine;
|
||||
private final NotebookService notebooks;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For the QContext's instance.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchApi() throws QNotFoundException
|
||||
{
|
||||
this(QContext.getQInstance(), SqlWorkbenchRuntime.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchApi(QInstance qInstance, SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.runtime = runtime;
|
||||
this.config = runtime.getConfig();
|
||||
this.engine = new SqlExecutionEngine(qInstance, runtime);
|
||||
this.notebooks = new NotebookService(runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The caller, after checking the access permission.
|
||||
*******************************************************************************/
|
||||
public Caller caller(String clientIp) throws QException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null)
|
||||
{
|
||||
throw (new QAuthenticationException("Not authenticated."));
|
||||
}
|
||||
if(!session.hasPermission(config.getAccessPermissionName()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
|
||||
String userId = session.getUser() != null && StringUtils.hasContent(session.getUser().getIdReference()) ? session.getUser().getIdReference() : session.getIdReference();
|
||||
String userName = session.getUser() != null && StringUtils.hasContent(session.getUser().getFullName()) ? session.getUser().getFullName() : userId;
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
throw (new QAuthenticationException("The session has no user."));
|
||||
}
|
||||
|
||||
boolean canWrite = Boolean.TRUE.equals(config.getWriteModeEnabled()) && session.hasPermission(config.getWritePermissionName());
|
||||
boolean canShare = session.hasPermission(config.getSharePermissionName());
|
||||
return (new Caller(userId, userName, canWrite, canShare, clientIp));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /capabilities
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> capabilities(Caller caller)
|
||||
{
|
||||
Map<String, Object> limits = new LinkedHashMap<>();
|
||||
limits.put("maxRows", config.getMaxRows());
|
||||
limits.put("pageSize", config.getDefaultPageSize());
|
||||
limits.put("maxPageSize", config.getMaxPageSize());
|
||||
limits.put("maxResultBytes", config.getMaxResultBytes());
|
||||
limits.put("statementTimeoutSeconds", config.getStatementTimeoutSeconds());
|
||||
limits.put("writeStatementTimeoutSeconds", config.getWriteStatementTimeoutSeconds());
|
||||
limits.put("maxAffectedRowsWithoutAcknowledgement", config.getMaxAffectedRowsWithoutAcknowledgement());
|
||||
limits.put("maxAffectedRows", config.getMaxAffectedRows());
|
||||
limits.put("maxSqlLength", config.getMaxSqlLength());
|
||||
limits.put("maxStatementsPerRun", config.getMaxStatementsPerRun());
|
||||
|
||||
List<Map<String, Object>> backends = new ArrayList<>();
|
||||
for(WorkbenchBackends.Target target : engine.getBackends().listTargets())
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("name", target.name());
|
||||
view.put("label", target.name());
|
||||
view.put("vendor", target.backend().getVendor());
|
||||
view.put("dialect", target.dialect().getUiName());
|
||||
view.put("readOnlyCredentials", engine.getBackends().hasReadOnlyCredentials(target));
|
||||
view.put("readOnlyEnforced", target.dialect() != SqlDialect.H2 && target.dialect() != SqlDialect.GENERIC || engine.getBackends().hasReadOnlyCredentials(target));
|
||||
view.put("transactionalDdl", target.dialect().isTransactionalDdl());
|
||||
view.put("writeAllowed", caller.canWrite());
|
||||
backends.add(view);
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("version", SqlWorkbenchQBitProducer.getVersion());
|
||||
body.put("canWrite", caller.canWrite());
|
||||
body.put("canShare", caller.canShare());
|
||||
body.put("writeModeEnabled", config.getWriteModeEnabled());
|
||||
body.put("limits", limits);
|
||||
body.put("backends", backends);
|
||||
return (body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /schema/{backendName}
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> schema(Caller caller, String backendName, boolean refresh) throws QException
|
||||
{
|
||||
return (new SchemaIntrospector(qInstance, runtime, engine.getBackends()).describe(backendName, refresh));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /execute
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> execute(Caller caller, Map<String, Object> body) throws QException
|
||||
{
|
||||
ExecutionRequest request = new ExecutionRequest(
|
||||
requireString(body, "backendName"),
|
||||
requireString(body, "sql"),
|
||||
paramsOf(body),
|
||||
"WRITE".equalsIgnoreCase(String.valueOf(body.get("mode"))),
|
||||
intOrNull(body.get("notebookId")),
|
||||
body.get("cellKey") == null ? null : body.get("cellKey").toString(),
|
||||
intOrNull(body.get("pageSize")),
|
||||
body.get("confirmToken") == null ? null : body.get("confirmToken").toString(),
|
||||
Boolean.TRUE.equals(body.get("acknowledgeRowGuard")),
|
||||
body.get("executionUuid") == null ? null : normalizeUuid(body.get("executionUuid").toString()));
|
||||
return (engine.execute(request, caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /explain
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> explain(Caller caller, Map<String, Object> body) throws QException
|
||||
{
|
||||
return (engine.explain(requireString(body, "backendName"), requireString(body, "sql"), paramsOf(body), Boolean.TRUE.equals(body.get("analyze")), caller));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /executions/{uuid}/rows
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> rows(Caller caller, String executionUuid, Integer offset, Integer limit, Integer sortColumn, String direction) throws QException
|
||||
{
|
||||
String uuid = normalizeUuid(executionUuid);
|
||||
ResultCache.Entry entry = cached(caller, uuid);
|
||||
int from = offset == null ? 0 : Math.max(0, offset);
|
||||
int size = limit == null || limit <= 0 ? config.getDefaultPageSize() : Math.min(limit, config.getMaxPageSize());
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("executionUuid", uuid);
|
||||
body.put("columns", entry.result().columns().stream().map(ColumnInfo::toMap).toList());
|
||||
body.put("rows", ResultCache.page(entry.result(), from, size, sortColumn, "desc".equalsIgnoreCase(direction)));
|
||||
body.put("offset", from);
|
||||
body.put("rowCount", entry.result().rows().size());
|
||||
body.put("truncated", entry.result().truncated());
|
||||
body.put("truncatedReason", entry.result().truncatedReason());
|
||||
return (body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /executions/{uuid}/export - the cached entry to write.
|
||||
*******************************************************************************/
|
||||
public ResultCache.Entry exportable(Caller caller, String executionUuid) throws QException
|
||||
{
|
||||
return (cached(caller, normalizeUuid(executionUuid)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /executions/{uuid}/cancel
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> cancel(Caller caller, String executionUuid)
|
||||
{
|
||||
return (Map.of("cancelled", engine.cancel(normalizeUuid(executionUuid), caller)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /history - the caller's own executions, newest first.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> history(Caller caller, Integer limit, String backendName) throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, caller.userId()))
|
||||
.withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withLimit(limit == null || limit <= 0 ? 50 : Math.min(limit, 200));
|
||||
if(StringUtils.hasContent(backendName))
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("backendName", QCriteriaOperator.EQUALS, backendName));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> executions = new ArrayList<>();
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// the audit table needs the admin permission; the filter above is //
|
||||
// the boundary (own rows only), so read it as the system user //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
for(QRecord record : SystemUser.call(() -> QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME), filter)))
|
||||
{
|
||||
SqlWorkbenchExecution execution = new SqlWorkbenchExecution(record);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("executionUuid", execution.getExecutionUuid());
|
||||
view.put("backendName", execution.getBackendName());
|
||||
view.put("mode", execution.getMode());
|
||||
view.put("status", execution.getStatus());
|
||||
view.put("sqlText", execution.getSqlText());
|
||||
view.put("notebookId", execution.getNotebookId());
|
||||
view.put("cellKey", execution.getCellKey());
|
||||
view.put("startTime", execution.getStartTime() == null ? null : execution.getStartTime().toString());
|
||||
view.put("durationMs", execution.getDurationMs());
|
||||
view.put("rowsReturned", execution.getRowsReturned());
|
||||
view.put("rowsAffected", execution.getRowsAffected());
|
||||
view.put("errorMessage", execution.getErrorMessage());
|
||||
executions.add(view);
|
||||
}
|
||||
return (Map.of("executions", executions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook endpoints.
|
||||
*******************************************************************************/
|
||||
public NotebookService notebooks()
|
||||
{
|
||||
return (notebooks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /notebooks/{id}/snapshots - the snapshot keeps the audited SQL.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> createSnapshot(Caller caller, Integer notebookId, Map<String, Object> body) throws QException
|
||||
{
|
||||
String executionUuid = normalizeUuid(requireString(body, "executionUuid"));
|
||||
cached(caller, executionUuid);
|
||||
List<QRecord> audit = SystemUser.call(() -> QueryAction.execute(config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME),
|
||||
new QQueryFilter(new QFilterCriteria("executionUuid", QCriteriaOperator.EQUALS, executionUuid))
|
||||
.withCriteria(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, caller.userId()))
|
||||
.withLimit(1)));
|
||||
String sqlText = audit.isEmpty() ? null : audit.get(0).getValueString("sqlText");
|
||||
|
||||
Map<String, Object> normalized = new LinkedHashMap<>(body);
|
||||
normalized.put("executionUuid", executionUuid);
|
||||
return (notebooks.createSnapshot(notebookId, normalized, caller, sqlText));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private ResultCache.Entry cached(Caller caller, String executionUuid) throws QNotFoundException
|
||||
{
|
||||
return (runtime.getResultCache().get(executionUuid, caller.userId())
|
||||
.orElseThrow(() -> new QNotFoundException("That result is no longer available; run the SQL again.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execution ids are stored and cached lower-case; accept any case (and
|
||||
** surrounding blanks) from clients.
|
||||
*******************************************************************************/
|
||||
static String normalizeUuid(String executionUuid)
|
||||
{
|
||||
return (executionUuid == null ? null : executionUuid.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> paramsOf(Map<String, Object> body) throws QBadRequestException
|
||||
{
|
||||
Object params = body.get("params");
|
||||
if(params == null)
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
if(!(params instanceof Map<?, ?>))
|
||||
{
|
||||
throw (new QBadRequestException("params must be an object of name: value."));
|
||||
}
|
||||
return (new LinkedHashMap<>((Map<String, Object>) params));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String requireString(Map<String, Object> body, String key) throws QBadRequestException
|
||||
{
|
||||
Object value = body == null ? null : body.get(key);
|
||||
if(value == null || !StringUtils.hasContent(value.toString()))
|
||||
{
|
||||
throw (new QBadRequestException(key + " is required."));
|
||||
}
|
||||
return (value.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Integer intOrNull(Object value) throws QBadRequestException
|
||||
{
|
||||
if(value == null || "".equals(value))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (n.intValue());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(value.toString().trim()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException("Expected an integer, got: " + value));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.DuplicateExecutionException;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.ContentType;
|
||||
import io.javalin.http.Context;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Javalin routes for the SQL Workbench under /qqq/v1/sql-workbench (see
|
||||
** docs/DESIGN.md section 5 for the contract).
|
||||
**
|
||||
** Each request gets a session from the instance's authentication module
|
||||
** (ExecutorSessionUtils), then SqlWorkbenchApi checks permissions and does
|
||||
** the work. Errors map through QJavalinUtils.handleException: 400 bad
|
||||
** request, 401 unauthenticated, 403 not permitted, 404 unknown, 409 a
|
||||
** re-used executionUuid. POST / PUT
|
||||
** bodies must be JSON (Content-Type application/json): a cross-site form
|
||||
** cannot send that without a CORS preflight. Request and response bodies
|
||||
** are never logged.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
public static final String BASE_PATH = "/qqq/v1/sql-workbench";
|
||||
|
||||
private QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setQInstance(QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get(BASE_PATH + "/capabilities", ctx -> respond(ctx, false, (api, caller, body) -> api.capabilities(caller)));
|
||||
config.routes.get(BASE_PATH + "/schema/{backendName}", ctx -> respond(ctx, false, (api, caller, body) -> api.schema(caller, ctx.pathParam("backendName"), "true".equalsIgnoreCase(ctx.queryParam("refresh")))));
|
||||
config.routes.post(BASE_PATH + "/execute", ctx -> respond(ctx, true, (api, caller, body) -> api.execute(caller, body)));
|
||||
config.routes.post(BASE_PATH + "/explain", ctx -> respond(ctx, true, (api, caller, body) -> api.explain(caller, body)));
|
||||
config.routes.get(BASE_PATH + "/executions/{uuid}/rows", ctx -> respond(ctx, false, (api, caller, body) -> api.rows(caller, ctx.pathParam("uuid"),
|
||||
intParam(ctx, "offset"), intParam(ctx, "limit"), intParam(ctx, "sort"), ctx.queryParam("dir"))));
|
||||
config.routes.get(BASE_PATH + "/executions/{uuid}/export", this::export);
|
||||
config.routes.post(BASE_PATH + "/executions/{uuid}/cancel", ctx -> respond(ctx, true, (api, caller, body) -> api.cancel(caller, ctx.pathParam("uuid"))));
|
||||
config.routes.get(BASE_PATH + "/history", ctx -> respond(ctx, false, (api, caller, body) -> api.history(caller, intParam(ctx, "limit"), ctx.queryParam("backendName"))));
|
||||
|
||||
config.routes.get(BASE_PATH + "/notebooks", ctx -> respond(ctx, false, (api, caller, body) -> Map.of("notebooks", api.notebooks().list(ctx.queryParam("scope"), caller))));
|
||||
config.routes.post(BASE_PATH + "/notebooks", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().create(body, caller)));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}", ctx -> respond(ctx, false, (api, caller, body) -> api.notebooks().get(id(ctx), caller)));
|
||||
config.routes.put(BASE_PATH + "/notebooks/{id}", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().update(id(ctx), body, caller)));
|
||||
config.routes.delete(BASE_PATH + "/notebooks/{id}", ctx -> respond(ctx, false, (api, caller, body) ->
|
||||
{
|
||||
api.notebooks().delete(id(ctx), caller);
|
||||
return (Map.of("deleted", true));
|
||||
}));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/publish", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().publish(id(ctx), Boolean.TRUE.equals(body.get("shared")), caller)));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/duplicate", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().duplicate(id(ctx), caller)));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}/versions", ctx -> respond(ctx, false, (api, caller, body) -> Map.of("versions", api.notebooks().versions(id(ctx), caller))));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}/versions/{n}", ctx -> respond(ctx, false, (api, caller, body) -> api.notebooks().version(id(ctx), pathInt(ctx, "n"), caller)));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/versions/{n}/restore", ctx -> respond(ctx, true, (api, caller, body) -> api.notebooks().restore(id(ctx), pathInt(ctx, "n"), caller)));
|
||||
config.routes.get(BASE_PATH + "/notebooks/{id}/snapshots", ctx -> respond(ctx, false, (api, caller, body) -> Map.of("snapshots", api.notebooks().snapshots(id(ctx), caller))));
|
||||
config.routes.post(BASE_PATH + "/notebooks/{id}/snapshots", ctx -> respond(ctx, true, (api, caller, body) -> api.createSnapshot(caller, id(ctx), body)));
|
||||
config.routes.delete(BASE_PATH + "/notebooks/{id}/snapshots/{snapshotId}", ctx -> respond(ctx, false, (api, caller, body) ->
|
||||
{
|
||||
api.notebooks().deleteSnapshot(id(ctx), pathInt(ctx, "snapshotId"), caller);
|
||||
return (Map.of("deleted", true));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session, permission check, body, handler, JSON - or the error's status.
|
||||
*******************************************************************************/
|
||||
private void respond(Context context, boolean readsBody, Handler handler)
|
||||
{
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, qInstance);
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Caller caller = api.caller(context.ip());
|
||||
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
|
||||
Object result = handler.handle(api, caller, body);
|
||||
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
QJavalinUtils.handleException(e instanceof DuplicateExecutionException ? HttpStatus.Code.CONFLICT : null, context, e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /executions/{uuid}/export?format=csv|json|xlsx
|
||||
*******************************************************************************/
|
||||
private void export(Context context)
|
||||
{
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, qInstance);
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Caller caller = api.caller(context.ip());
|
||||
ResultExporter.Format format = ResultExporter.Format.of(StringUtils.hasContent(context.queryParam("format")) ? context.queryParam("format") : "csv");
|
||||
String uuid = context.pathParam("uuid");
|
||||
ResultCache.Entry entry = api.exportable(caller, uuid);
|
||||
|
||||
String shortId = uuid.length() > 8 ? uuid.substring(0, 8) : uuid;
|
||||
context.contentType(format.getContentType());
|
||||
context.header("Content-Disposition", "attachment; filename=\"sql-result-" + shortId.replaceAll("[^A-Za-z0-9-]", "") + "." + format.getExtension() + "\"");
|
||||
context.header("Cache-Control", "no-store");
|
||||
ResultExporter.write(entry.result(), format, context.outputStream());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
QJavalinUtils.handleException(null, context, e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A JSON object body. Decimals stay exact (BigDecimal).
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> readJsonBody(Context context) throws QBadRequestException
|
||||
{
|
||||
String body = context.body();
|
||||
if(!StringUtils.hasContent(body))
|
||||
{
|
||||
return (new LinkedHashMap<>());
|
||||
}
|
||||
|
||||
String contentType = context.contentType();
|
||||
if(contentType == null || !contentType.toLowerCase().startsWith("application/json"))
|
||||
{
|
||||
throw (new QBadRequestException("Request bodies must be JSON (Content-Type: application/json)."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Map<String, Object> map = JsonUtils.toObject(body, new TypeReference<Map<String, Object>>() {}, mapper -> mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS));
|
||||
return (map == null ? new LinkedHashMap<>() : map);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QBadRequestException("The request body is not a JSON object."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer id(Context context) throws QBadRequestException
|
||||
{
|
||||
return (pathInt(context, "id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer pathInt(Context context, String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(context.pathParam(name)));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be an integer."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer intParam(Context context, String name) throws QBadRequestException
|
||||
{
|
||||
String value = context.queryParam(name);
|
||||
if(!StringUtils.hasContent(value))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Integer.parseInt(value.trim()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be an integer."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One endpoint's work.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Handler
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Object handle(SqlWorkbenchApi api, Caller caller, Map<String, Object> body) throws QException;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers SqlWorkbenchRouteProvider (the /qqq/v1/sql-workbench routes)
|
||||
** with the Javalin server through the instance's QJavalinMetaData - as
|
||||
** qqq-esb's EsbJavalinMetaDataProducer does. Only touched when
|
||||
** qqq-middleware-javalin is on the classpath (the producer checks first).
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchRoutes
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the route provider reference, once.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
QJavalinMetaData javalinMetaData = QJavalinMetaData.ofOrWithNew(qInstance);
|
||||
boolean registered = CollectionUtils.nonNullList(javalinMetaData.getAdditionalRouteProviderReferences()).stream()
|
||||
.anyMatch(reference -> reference != null && SqlWorkbenchRouteProvider.class.getName().equals(reference.getName()));
|
||||
if(!registered)
|
||||
{
|
||||
javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(SqlWorkbenchRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who is calling, and what they may do (resolved from the QSession by the
|
||||
** API layer).
|
||||
**
|
||||
** @param userId the session user's idReference (owner key everywhere)
|
||||
** @param userName display name
|
||||
** @param canWrite has the write permission (and write mode is enabled)
|
||||
** @param canShare has the share permission
|
||||
** @param clientIp request address, when known
|
||||
*******************************************************************************/
|
||||
public record Caller(String userId, String userName, boolean canWrite, boolean canShare, String clientIp)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A result set, read into memory within the configured limits.
|
||||
**
|
||||
** @param columns the columns
|
||||
** @param rows the rows (values already converted to JSON-safe
|
||||
** types by ResultCollector)
|
||||
** @param truncated whether a limit stopped collection
|
||||
** @param truncatedReason ROWS or BYTES when truncated, else null
|
||||
** @param estimatedBytes estimated size of the collected values
|
||||
*******************************************************************************/
|
||||
public record CollectedResult(List<ColumnInfo> columns, List<List<Object>> rows, boolean truncated, String truncatedReason, long estimatedBytes)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One result column.
|
||||
**
|
||||
** @param name column name from the driver
|
||||
** @param label column label (alias) from the driver
|
||||
** @param typeName database type name (e.g. int4, jsonb, VARCHAR)
|
||||
** @param jdbcType java.sql.Types code
|
||||
** @param kind how the UI should render it: number, string, boolean,
|
||||
** date, datetime, time, json, binary, array, other
|
||||
*******************************************************************************/
|
||||
public record ColumnInfo(String name, String label, String typeName, int jdbcType, String kind)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON view of this column.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> toMap()
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", name);
|
||||
map.put("label", label);
|
||||
map.put("typeName", typeName);
|
||||
map.put("jdbcType", jdbcType);
|
||||
map.put("kind", kind);
|
||||
return (map);
|
||||
}
|
||||
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client-chosen executionUuid that is already running or already in the
|
||||
** audit. Execution ids are single-use; the route provider answers 409.
|
||||
*******************************************************************************/
|
||||
public class DuplicateExecutionException extends QBadRequestException
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public DuplicateExecutionException(String executionUuid)
|
||||
{
|
||||
super("executionUuid " + executionUuid + " was already used; send a new UUID for each run.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes the execution audit: a RUNNING row before the SQL runs (if that
|
||||
** fails, the SQL does not run) and the outcome after. Writes go through QQQ
|
||||
** actions in a system-session context, because the audit table's insert
|
||||
** and edit capabilities are disabled for users.
|
||||
*******************************************************************************/
|
||||
public class ExecutionAuditor
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(ExecutionAuditor.class);
|
||||
|
||||
public static final int MAX_ERROR_LENGTH = 4000;
|
||||
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ExecutionAuditor(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The audit table's (possibly prefixed) name.
|
||||
*******************************************************************************/
|
||||
public String tableName()
|
||||
{
|
||||
return (config.applyPrefix(SqlWorkbenchExecution.TABLE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert the RUNNING row.
|
||||
**
|
||||
** @return the row's id
|
||||
*******************************************************************************/
|
||||
public Integer start(SqlWorkbenchExecution execution) throws QException
|
||||
{
|
||||
execution.setStatus(ExecutionStatus.RUNNING.name());
|
||||
execution.setCreateDate(execution.getStartTime());
|
||||
QRecord[] inserted = new QRecord[1];
|
||||
asSystem(() ->
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(tableName()).withRecordEntity(execution));
|
||||
inserted[0] = output.getRecords().get(0);
|
||||
});
|
||||
|
||||
throwIfNotInserted(inserted[0], "Could not write the SQL Workbench audit record; the SQL was not run.");
|
||||
execution.setId(inserted[0].getValueInteger("id"));
|
||||
return (execution.getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the outcome.
|
||||
*******************************************************************************/
|
||||
public void finish(SqlWorkbenchExecution execution, ExecutionStatus status, Instant endTime, Integer rowsReturned, Long rowsAffected, Boolean truncated, String errorMessage) throws QException
|
||||
{
|
||||
execution.setStatus(status.name());
|
||||
execution.setEndTime(endTime);
|
||||
execution.setDurationMs(execution.getStartTime() == null ? null : (int) Math.min(Integer.MAX_VALUE, Duration.between(execution.getStartTime(), endTime).toMillis()));
|
||||
execution.setRowsReturned(rowsReturned);
|
||||
execution.setRowsAffected(rowsAffected == null ? null : (int) Math.min(Integer.MAX_VALUE, rowsAffected));
|
||||
execution.setTruncated(truncated);
|
||||
execution.setErrorMessage(truncate(errorMessage));
|
||||
|
||||
QRecord record = new QRecord()
|
||||
.withValue("id", execution.getId())
|
||||
.withValue("status", execution.getStatus())
|
||||
.withValue("endTime", execution.getEndTime())
|
||||
.withValue("durationMs", execution.getDurationMs())
|
||||
.withValue("rowsReturned", execution.getRowsReturned())
|
||||
.withValue("rowsAffected", execution.getRowsAffected())
|
||||
.withValue("truncated", execution.getTruncated())
|
||||
.withValue("errorMessage", execution.getErrorMessage());
|
||||
|
||||
asSystem(() -> new UpdateAction().execute(new UpdateInput(tableName()).withRecords(List.of(record))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a complete row for a run that never started (e.g. rejected by
|
||||
** validation or permissions).
|
||||
*******************************************************************************/
|
||||
public void record(SqlWorkbenchExecution execution) throws QException
|
||||
{
|
||||
execution.setCreateDate(execution.getStartTime());
|
||||
execution.setErrorMessage(truncate(execution.getErrorMessage()));
|
||||
QRecord[] inserted = new QRecord[1];
|
||||
asSystem(() -> inserted[0] = new InsertAction().execute(new InsertInput(tableName()).withRecordEntity(execution)).getRecords().get(0));
|
||||
throwIfNotInserted(inserted[0], "Could not write the SQL Workbench audit record of a rejected request.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the audit already holds a row with this execution id.
|
||||
*******************************************************************************/
|
||||
public boolean exists(String executionUuid) throws QException
|
||||
{
|
||||
return (SystemUser.call(() -> !QueryAction.execute(tableName(), new QQueryFilter(new QFilterCriteria("executionUuid", QCriteriaOperator.EQUALS, executionUuid)).withLimit(1)).isEmpty()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail loudly when an audit insert did not produce a row (record errors,
|
||||
** e.g. a duplicate executionUuid, or no generated id).
|
||||
*******************************************************************************/
|
||||
private static void throwIfNotInserted(QRecord record, String message) throws QException
|
||||
{
|
||||
if(record == null || (record.getErrors() != null && !record.getErrors().isEmpty()) || record.getValueInteger("id") == null)
|
||||
{
|
||||
String detail = record == null || record.getErrors() == null || record.getErrors().isEmpty() ? "no id returned" : record.getErrors().get(0).getMessage();
|
||||
LOG.warn(message, logPair("detail", detail));
|
||||
throw (new QException(message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String truncate(String message)
|
||||
{
|
||||
if(message == null || message.length() <= MAX_ERROR_LENGTH)
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
return (message.substring(0, MAX_ERROR_LENGTH - 1) + "…");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run as the system user, in the current instance.
|
||||
*******************************************************************************/
|
||||
static void asSystem(SystemUser.Block block) throws QException
|
||||
{
|
||||
SystemUser.run(block);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How an execution ran, as recorded in the audit.
|
||||
*******************************************************************************/
|
||||
public enum ExecutionMode
|
||||
{
|
||||
READ_ONLY,
|
||||
WRITE_PREVIEW,
|
||||
WRITE_COMMIT,
|
||||
EXPLAIN,
|
||||
EXPLAIN_ANALYZE
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A request to run SQL.
|
||||
**
|
||||
** @param backendName target backend
|
||||
** @param sql the SQL text
|
||||
** @param params values for :name parameters (JSON types)
|
||||
** @param write true for write mode
|
||||
** @param notebookId where it ran from (optional)
|
||||
** @param cellKey which cell (optional)
|
||||
** @param pageSize rows in the first page (optional)
|
||||
** @param confirmToken write mode: the preview's token (commit)
|
||||
** @param acknowledgeRowGuard write mode: the caller accepted the row guard
|
||||
** @param executionUuid optional client-chosen id (a UUID), so the client
|
||||
** can cancel the run before it answers; generated
|
||||
** when absent
|
||||
*******************************************************************************/
|
||||
public record ExecutionRequest(String backendName, String sql, Map<String, Object> params, boolean write, Integer notebookId, String cellKey,
|
||||
Integer pageSize, String confirmToken, boolean acknowledgeRowGuard, String executionUuid)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a client-chosen execution id.
|
||||
*******************************************************************************/
|
||||
public ExecutionRequest(String backendName, String sql, Map<String, Object> params, boolean write, Integer notebookId, String cellKey,
|
||||
Integer pageSize, String confirmToken, boolean acknowledgeRowGuard)
|
||||
{
|
||||
this(backendName, sql, params, write, notebookId, cellKey, pageSize, confirmToken, acknowledgeRowGuard, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only request.
|
||||
*******************************************************************************/
|
||||
public static ExecutionRequest readOnly(String backendName, String sql, Map<String, Object> params)
|
||||
{
|
||||
return (new ExecutionRequest(backendName, sql, params, false, null, null, null, null, false));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of an execution, as recorded in the audit.
|
||||
*******************************************************************************/
|
||||
public enum ExecutionStatus
|
||||
{
|
||||
RUNNING,
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
TIMED_OUT,
|
||||
REJECTED,
|
||||
ROLLED_BACK,
|
||||
DRIFTED
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** In-memory, owner-scoped cache of collected results, so the UI can page,
|
||||
** sort and export without re-running SQL. Bounded by entry count (LRU) and
|
||||
** TTL. Entries belong to the user who ran the SQL; any other user gets
|
||||
** nothing (and the API turns that into a 404).
|
||||
*******************************************************************************/
|
||||
public class ResultCache
|
||||
{
|
||||
private final int maxEntries;
|
||||
private final long ttlMillis;
|
||||
private final Clock clock;
|
||||
|
||||
private final LinkedHashMap<String, Entry> entries = new LinkedHashMap<>(16, 0.75f, true);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One cached result.
|
||||
**
|
||||
** @param executionUuid the execution's id
|
||||
** @param userId owner
|
||||
** @param backendName where it ran
|
||||
** @param result the collected result
|
||||
** @param createdAt when it was cached
|
||||
*******************************************************************************/
|
||||
public record Entry(String executionUuid, String userId, String backendName, CollectedResult result, Instant createdAt)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ResultCache(int maxEntries, int ttlSeconds, Clock clock)
|
||||
{
|
||||
this.maxEntries = maxEntries;
|
||||
this.ttlMillis = ttlSeconds * 1000L;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store a result, evicting expired and least recently used entries.
|
||||
*******************************************************************************/
|
||||
public synchronized void put(String executionUuid, String userId, String backendName, CollectedResult result)
|
||||
{
|
||||
purgeExpired();
|
||||
entries.put(executionUuid, new Entry(executionUuid, userId, backendName, result, clock.instant()));
|
||||
while(entries.size() > maxEntries)
|
||||
{
|
||||
String eldest = entries.keySet().iterator().next();
|
||||
entries.remove(eldest);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get a result, only for its owner and only while it has not expired.
|
||||
*******************************************************************************/
|
||||
public synchronized Optional<Entry> get(String executionUuid, String userId)
|
||||
{
|
||||
purgeExpired();
|
||||
Entry entry = entries.get(executionUuid);
|
||||
if(entry == null || !Objects.equals(entry.userId(), userId))
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
return (Optional.of(entry));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public synchronized int size()
|
||||
{
|
||||
purgeExpired();
|
||||
return (entries.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public synchronized void clear()
|
||||
{
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void purgeExpired()
|
||||
{
|
||||
Instant cutoff = clock.instant().minusMillis(ttlMillis);
|
||||
entries.values().removeIf(e -> e.createdAt().isBefore(cutoff));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows of a result, optionally sorted by one column (type-aware, nulls
|
||||
** last), then sliced.
|
||||
*******************************************************************************/
|
||||
public static List<List<Object>> page(CollectedResult result, int offset, int limit, Integer sortColumn, boolean descending)
|
||||
{
|
||||
List<List<Object>> rows = result.rows();
|
||||
if(sortColumn != null && sortColumn >= 0 && sortColumn < result.columns().size())
|
||||
{
|
||||
rows = new ArrayList<>(rows);
|
||||
int column = sortColumn;
|
||||
rows.sort((a, b) -> compareNullsLast(a.get(column), b.get(column), descending));
|
||||
}
|
||||
|
||||
int from = Math.max(0, Math.min(offset, rows.size()));
|
||||
int to = Math.max(from, Math.min(rows.size(), from + Math.max(0, limit)));
|
||||
return (new ArrayList<>(rows.subList(from, to)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare two values, nulls last in either direction.
|
||||
*******************************************************************************/
|
||||
static int compareNullsLast(Object a, Object b, boolean descending)
|
||||
{
|
||||
if(a == null || b == null)
|
||||
{
|
||||
return (a == null ? (b == null ? 0 : 1) : -1);
|
||||
}
|
||||
return (descending ? compareValues(b, a) : compareValues(a, b));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare two non-null converted values: numbers numerically (numeric
|
||||
** strings too), booleans, else as strings.
|
||||
*******************************************************************************/
|
||||
static int compareValues(Object a, Object b)
|
||||
{
|
||||
BigDecimal na = asNumber(a);
|
||||
BigDecimal nb = asNumber(b);
|
||||
if(na != null && nb != null)
|
||||
{
|
||||
return (na.compareTo(nb));
|
||||
}
|
||||
if(a instanceof Boolean ba && b instanceof Boolean bb)
|
||||
{
|
||||
return (ba.compareTo(bb));
|
||||
}
|
||||
return (String.valueOf(a).compareTo(String.valueOf(b)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static BigDecimal asNumber(Object value)
|
||||
{
|
||||
if(value instanceof BigDecimal bd)
|
||||
{
|
||||
return (bd);
|
||||
}
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (new BigDecimal(n.toString()));
|
||||
}
|
||||
if(value instanceof String s && !s.isEmpty() && s.length() < 64)
|
||||
{
|
||||
char c = s.charAt(0);
|
||||
if(Character.isDigit(c) || c == '-' || c == '+' || c == '.')
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new BigDecimal(s));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For tests and diagnostics: the cached execution ids, oldest first.
|
||||
*******************************************************************************/
|
||||
synchronized List<String> keys()
|
||||
{
|
||||
return (new ArrayList<>(entries.keySet()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.sql.Array;
|
||||
import java.sql.Blob;
|
||||
import java.sql.Clob;
|
||||
import java.sql.Date;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Time;
|
||||
import java.sql.Timestamp;
|
||||
import java.sql.Types;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads a ResultSet into JSON-safe values, stopping at the row cap or the
|
||||
** byte budget.
|
||||
**
|
||||
** Conversions: null stays null; integers and decimals within +/-2^53 are
|
||||
** numbers, wider ones are strings (no precision loss in JavaScript);
|
||||
** dates/times are ISO-8601 strings; json/jsonb are strings (kind json);
|
||||
** binary is {"$binary": true, "length": n, "hexPreview": "..."}; arrays are
|
||||
** lists; anything else is its toString, capped at maxCellChars.
|
||||
*******************************************************************************/
|
||||
public class ResultCollector
|
||||
{
|
||||
private static final BigDecimal MAX_SAFE = BigDecimal.valueOf(9007199254740991L);
|
||||
private static final int HEX_PREVIEW_BYTES = 32;
|
||||
|
||||
private final int maxRows;
|
||||
private final long maxBytes;
|
||||
private final int maxCellChars;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ResultCollector(int maxRows, long maxBytes, int maxCellChars)
|
||||
{
|
||||
this.maxRows = maxRows;
|
||||
this.maxBytes = maxBytes;
|
||||
this.maxCellChars = maxCellChars;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the columns of a result set.
|
||||
*******************************************************************************/
|
||||
public static List<ColumnInfo> readColumns(ResultSetMetaData metaData) throws SQLException
|
||||
{
|
||||
List<ColumnInfo> columns = new ArrayList<>();
|
||||
for(int i = 1; i <= metaData.getColumnCount(); i++)
|
||||
{
|
||||
String typeName = metaData.getColumnTypeName(i);
|
||||
int jdbcType = metaData.getColumnType(i);
|
||||
columns.add(new ColumnInfo(metaData.getColumnName(i), metaData.getColumnLabel(i), typeName, jdbcType, kindOf(jdbcType, typeName)));
|
||||
}
|
||||
return (columns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collect rows until the result ends, maxRows rows are read (then one more
|
||||
** row is probed to detect truncation), or the byte budget runs out.
|
||||
*******************************************************************************/
|
||||
public CollectedResult collect(ResultSet resultSet) throws SQLException
|
||||
{
|
||||
List<ColumnInfo> columns = readColumns(resultSet.getMetaData());
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
long bytes = 0;
|
||||
|
||||
while(resultSet.next())
|
||||
{
|
||||
if(rows.size() >= maxRows)
|
||||
{
|
||||
return (new CollectedResult(columns, rows, true, "ROWS", bytes));
|
||||
}
|
||||
|
||||
List<Object> row = new ArrayList<>(columns.size());
|
||||
for(int i = 0; i < columns.size(); i++)
|
||||
{
|
||||
Object value = readValue(resultSet, i + 1, columns.get(i));
|
||||
bytes += estimateBytes(value);
|
||||
row.add(value);
|
||||
}
|
||||
|
||||
if(bytes > maxBytes)
|
||||
{
|
||||
return (new CollectedResult(columns, rows, true, "BYTES", bytes));
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
return (new CollectedResult(columns, rows, false, null, bytes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a JDBC type to a UI rendering kind.
|
||||
*******************************************************************************/
|
||||
static String kindOf(int jdbcType, String typeName)
|
||||
{
|
||||
String type = typeName == null ? "" : typeName.toLowerCase(Locale.ROOT);
|
||||
if(type.equals("json") || type.equals("jsonb"))
|
||||
{
|
||||
return ("json");
|
||||
}
|
||||
|
||||
return switch(jdbcType)
|
||||
{
|
||||
case Types.TINYINT, Types.SMALLINT, Types.INTEGER, Types.BIGINT, Types.FLOAT, Types.REAL, Types.DOUBLE, Types.NUMERIC, Types.DECIMAL -> "number";
|
||||
case Types.BIT, Types.BOOLEAN -> type.startsWith("bit") && !type.equals("bit") ? "string" : "boolean";
|
||||
case Types.DATE -> "date";
|
||||
case Types.TIME, Types.TIME_WITH_TIMEZONE -> "time";
|
||||
case Types.TIMESTAMP, Types.TIMESTAMP_WITH_TIMEZONE -> "datetime";
|
||||
case Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY, Types.BLOB -> "binary";
|
||||
case Types.ARRAY -> "array";
|
||||
case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR, Types.CLOB, Types.NCLOB -> "string";
|
||||
default -> "other";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read and convert one value.
|
||||
*******************************************************************************/
|
||||
Object readValue(ResultSet resultSet, int index, ColumnInfo column) throws SQLException
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// pgjdbc reports timestamptz as TIMESTAMP; read it with its offset //
|
||||
// rather than shifting it into the JVM's zone //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
int jdbcType = column.jdbcType() == Types.TIMESTAMP && isZoned(column.typeName()) ? Types.TIMESTAMP_WITH_TIMEZONE : column.jdbcType();
|
||||
switch(jdbcType)
|
||||
{
|
||||
case Types.DATE ->
|
||||
{
|
||||
Object raw = resultSet.getObject(index);
|
||||
if(raw == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (raw instanceof Date date ? date.toLocalDate().toString() : convert(raw));
|
||||
}
|
||||
case Types.TIME ->
|
||||
{
|
||||
Object raw = resultSet.getObject(index);
|
||||
if(raw == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (raw instanceof Time time ? time.toLocalTime().toString() : convert(raw));
|
||||
}
|
||||
case Types.TIMESTAMP ->
|
||||
{
|
||||
Object raw = resultSet.getObject(index);
|
||||
if(raw == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (raw instanceof Timestamp timestamp ? timestamp.toLocalDateTime().toString() : convert(raw));
|
||||
}
|
||||
case Types.TIMESTAMP_WITH_TIMEZONE ->
|
||||
{
|
||||
try
|
||||
{
|
||||
OffsetDateTime value = resultSet.getObject(index, OffsetDateTime.class);
|
||||
return (value == null ? null : value.toString());
|
||||
}
|
||||
catch(SQLException | RuntimeException e)
|
||||
{
|
||||
return (convert(resultSet.getObject(index)));
|
||||
}
|
||||
}
|
||||
default ->
|
||||
{
|
||||
if("json".equals(column.kind()))
|
||||
{
|
||||
return (capString(resultSet.getString(index)));
|
||||
}
|
||||
return (convert(resultSet.getObject(index)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a type name is a zoned timestamp (timestamptz, timestamp with
|
||||
** time zone).
|
||||
*******************************************************************************/
|
||||
static boolean isZoned(String typeName)
|
||||
{
|
||||
String type = typeName == null ? "" : typeName.toLowerCase(Locale.ROOT);
|
||||
return (type.equals("timestamptz") || type.contains("with time zone"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert a driver value to a JSON-safe value.
|
||||
*******************************************************************************/
|
||||
Object convert(Object value) throws SQLException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(value instanceof Boolean || value instanceof Integer || value instanceof Short || value instanceof Byte)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
if(value instanceof Long l)
|
||||
{
|
||||
return (Math.abs(l) <= MAX_SAFE.longValue() ? l : l.toString());
|
||||
}
|
||||
if(value instanceof BigInteger bi)
|
||||
{
|
||||
return (bi.abs().compareTo(MAX_SAFE.toBigInteger()) <= 0 ? bi.longValue() : bi.toString());
|
||||
}
|
||||
if(value instanceof BigDecimal bd)
|
||||
{
|
||||
boolean safe = bd.abs().compareTo(MAX_SAFE) <= 0 && bd.precision() <= 15;
|
||||
return (safe ? bd : bd.toPlainString());
|
||||
}
|
||||
if(value instanceof Double d)
|
||||
{
|
||||
return (d.isNaN() || d.isInfinite() ? d.toString() : d);
|
||||
}
|
||||
if(value instanceof Float f)
|
||||
{
|
||||
return (f.isNaN() || f.isInfinite() ? f.toString() : f.doubleValue());
|
||||
}
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (n.toString());
|
||||
}
|
||||
if(value instanceof byte[] bytes)
|
||||
{
|
||||
return (binary(bytes, bytes.length));
|
||||
}
|
||||
if(value instanceof Blob blob)
|
||||
{
|
||||
long length = blob.length();
|
||||
return (binary(blob.getBytes(1, (int) Math.min(length, HEX_PREVIEW_BYTES)), length));
|
||||
}
|
||||
if(value instanceof Clob clob)
|
||||
{
|
||||
long length = clob.length();
|
||||
return (capString(clob.getSubString(1, (int) Math.min(length, maxCellChars + 1L))));
|
||||
}
|
||||
if(value instanceof Array array)
|
||||
{
|
||||
Object raw = array.getArray();
|
||||
List<Object> list = new ArrayList<>();
|
||||
if(raw instanceof Object[] objects)
|
||||
{
|
||||
for(Object o : objects)
|
||||
{
|
||||
list.add(convert(o));
|
||||
}
|
||||
}
|
||||
return (list);
|
||||
}
|
||||
if(value instanceof Timestamp timestamp)
|
||||
{
|
||||
return (timestamp.toLocalDateTime().toString());
|
||||
}
|
||||
if(value instanceof Date date)
|
||||
{
|
||||
return (date.toLocalDate().toString());
|
||||
}
|
||||
if(value instanceof Time time)
|
||||
{
|
||||
return (time.toLocalTime().toString());
|
||||
}
|
||||
if(value instanceof TemporalAccessor)
|
||||
{
|
||||
return (value.toString());
|
||||
}
|
||||
return (capString(value.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> binary(byte[] bytes, long length)
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("$binary", true);
|
||||
map.put("length", length);
|
||||
byte[] preview = bytes.length > HEX_PREVIEW_BYTES ? java.util.Arrays.copyOf(bytes, HEX_PREVIEW_BYTES) : bytes;
|
||||
map.put("hexPreview", HexFormat.of().formatHex(preview));
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String capString(String value)
|
||||
{
|
||||
if(value == null || value.length() <= maxCellChars)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
return (value.substring(0, maxCellChars) + "…");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rough in-memory / on-the-wire size of a converted value.
|
||||
*******************************************************************************/
|
||||
static long estimateBytes(Object value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (4);
|
||||
}
|
||||
if(value instanceof String s)
|
||||
{
|
||||
return (16L + 2L * s.length());
|
||||
}
|
||||
if(value instanceof List<?> list)
|
||||
{
|
||||
long total = 16;
|
||||
for(Object o : list)
|
||||
{
|
||||
total += estimateBytes(o);
|
||||
}
|
||||
return (total);
|
||||
}
|
||||
if(value instanceof Map<?, ?>)
|
||||
{
|
||||
return (128);
|
||||
}
|
||||
return (16);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.sql.Statement;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registry of executions in flight: enforces the per-user concurrency limit
|
||||
** and lets the owner cancel a running statement.
|
||||
*******************************************************************************/
|
||||
public class RunningStatements
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(RunningStatements.class);
|
||||
|
||||
private final Map<String, Running> running = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One execution in flight.
|
||||
*******************************************************************************/
|
||||
public static class Running
|
||||
{
|
||||
private final String userId;
|
||||
private volatile Statement statement;
|
||||
private volatile boolean cancelled;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Running(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the owner asked to cancel.
|
||||
*******************************************************************************/
|
||||
public boolean isCancelled()
|
||||
{
|
||||
return (cancelled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The statement now running (the engine swaps it per statement). If a
|
||||
** cancel already arrived, the statement is cancelled at once.
|
||||
*******************************************************************************/
|
||||
public void setStatement(Statement statement)
|
||||
{
|
||||
this.statement = statement;
|
||||
if(cancelled)
|
||||
{
|
||||
cancelQuietly(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register an execution, unless the user already has maxPerUser running.
|
||||
** An id that is already registered is refused (never overwritten), so a
|
||||
** re-sent id cannot replace - and later unregister - the original run.
|
||||
**
|
||||
** @return the registration, or null when the user is at the limit
|
||||
** @throws DuplicateExecutionException when the id is already running
|
||||
*******************************************************************************/
|
||||
public synchronized Running register(String executionUuid, String userId, int maxPerUser) throws DuplicateExecutionException
|
||||
{
|
||||
if(running.containsKey(executionUuid))
|
||||
{
|
||||
throw (new DuplicateExecutionException(executionUuid));
|
||||
}
|
||||
long mine = running.values().stream().filter(r -> Objects.equals(r.userId, userId)).count();
|
||||
if(mine >= maxPerUser)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Running registration = new Running(userId);
|
||||
if(running.putIfAbsent(executionUuid, registration) != null)
|
||||
{
|
||||
throw (new DuplicateExecutionException(executionUuid));
|
||||
}
|
||||
return (registration);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove this registration - only if it is still the one under the id.
|
||||
*******************************************************************************/
|
||||
public void unregister(String executionUuid, Running registration)
|
||||
{
|
||||
running.remove(executionUuid, registration);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an execution with this id is in flight.
|
||||
*******************************************************************************/
|
||||
public boolean isRunning(String executionUuid)
|
||||
{
|
||||
return (executionUuid != null && running.containsKey(executionUuid));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel an execution, only for its owner.
|
||||
**
|
||||
** @return whether a running execution of this user was found
|
||||
*******************************************************************************/
|
||||
public boolean cancel(String executionUuid, String userId)
|
||||
{
|
||||
Running registration = running.get(executionUuid);
|
||||
if(registration == null || !Objects.equals(registration.userId, userId))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
registration.cancelled = true;
|
||||
cancelQuietly(registration.statement);
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public int size()
|
||||
{
|
||||
return (running.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void cancelQuietly(Statement statement)
|
||||
{
|
||||
if(statement == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
statement.cancel();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.info("Could not cancel a SQL Workbench statement", logPair("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.concurrent.Executors;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wraps one workbench session on one connection: puts it in a transaction
|
||||
** (read-only where the database supports it), applies the statement
|
||||
** timeout, and on close always ends the transaction (rollback unless
|
||||
** committed) and restores every connection property it changed, so the
|
||||
** connection goes back to the pool clean. If restoring fails, the
|
||||
** connection is aborted rather than returned dirty.
|
||||
**
|
||||
** All engine-side settings are transaction-scoped (SET LOCAL) or reverted
|
||||
** here; user SQL can never change session state (the classifier forbids
|
||||
** SET / RESET / USE / PRAGMA assignments / transaction control).
|
||||
*******************************************************************************/
|
||||
public class SessionGuard implements AutoCloseable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SessionGuard.class);
|
||||
|
||||
private final Connection connection;
|
||||
private final SqlDialect dialect;
|
||||
private final boolean readOnly;
|
||||
private final int timeoutSeconds;
|
||||
|
||||
private boolean originalAutoCommit = true;
|
||||
private boolean originalReadOnly = false;
|
||||
private boolean sqliteQueryOnly = false;
|
||||
private boolean committed = false;
|
||||
private boolean began = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SessionGuard(Connection connection, SqlDialect dialect, boolean readOnly, int timeoutSeconds)
|
||||
{
|
||||
this.connection = connection;
|
||||
this.dialect = dialect;
|
||||
this.readOnly = readOnly;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start the session's transaction.
|
||||
*******************************************************************************/
|
||||
public SessionGuard begin() throws SQLException
|
||||
{
|
||||
originalAutoCommit = connection.getAutoCommit();
|
||||
originalReadOnly = connection.isReadOnly();
|
||||
began = true;
|
||||
|
||||
if(readOnly && dialect == SqlDialect.SQLITE)
|
||||
{
|
||||
execute("PRAGMA query_only = 1");
|
||||
sqliteQueryOnly = true;
|
||||
}
|
||||
|
||||
if(readOnly && dialect != SqlDialect.SQLITE)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// before the transaction starts: pgjdbc then begins it READ ONLY, //
|
||||
// Connector/J sets the session's transaction characteristic //
|
||||
// (reverted in close), H2 treats it as a hint. //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
connection.setReadOnly(true);
|
||||
}
|
||||
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
if(dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
if(readOnly)
|
||||
{
|
||||
execute("SET TRANSACTION READ ONLY");
|
||||
}
|
||||
long millis = timeoutSeconds * 1000L;
|
||||
execute("SET LOCAL statement_timeout = " + millis);
|
||||
execute("SET LOCAL lock_timeout = " + millis);
|
||||
execute("SET LOCAL idle_in_transaction_session_timeout = " + (millis + 60_000L));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the session's limits to a statement.
|
||||
*******************************************************************************/
|
||||
public void configure(Statement statement, int maxRows, int fetchSize) throws SQLException
|
||||
{
|
||||
statement.setQueryTimeout(timeoutSeconds);
|
||||
if(maxRows > 0)
|
||||
{
|
||||
statement.setMaxRows(maxRows);
|
||||
}
|
||||
if(fetchSize > 0)
|
||||
{
|
||||
//////////////////////////////////////////////////////
|
||||
// some drivers (H2) refuse a fetch size > max rows //
|
||||
//////////////////////////////////////////////////////
|
||||
statement.setFetchSize(maxRows > 0 ? Math.min(fetchSize, maxRows) : fetchSize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit (confirmed writes only).
|
||||
*******************************************************************************/
|
||||
public void commit() throws SQLException
|
||||
{
|
||||
connection.commit();
|
||||
committed = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Roll back now (the guard still restores and closes later).
|
||||
*******************************************************************************/
|
||||
public void rollback() throws SQLException
|
||||
{
|
||||
connection.rollback();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Connection getConnection()
|
||||
{
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** End the transaction, restore the connection, and release it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
boolean clean = true;
|
||||
try
|
||||
{
|
||||
if(began && !committed)
|
||||
{
|
||||
connection.rollback();
|
||||
}
|
||||
if(sqliteQueryOnly)
|
||||
{
|
||||
connection.setAutoCommit(true);
|
||||
execute("PRAGMA query_only = 0");
|
||||
}
|
||||
if(began)
|
||||
{
|
||||
connection.setAutoCommit(originalAutoCommit);
|
||||
if(connection.isReadOnly() != originalReadOnly)
|
||||
{
|
||||
connection.setReadOnly(originalReadOnly);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
clean = false;
|
||||
LOG.warn("SQL Workbench could not restore a connection; aborting it", logPair("dialect", dialect), logPair("message", e.getMessage()));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if(clean)
|
||||
{
|
||||
connection.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
abort();
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("SQL Workbench could not release a connection", logPair("message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Abort a connection that could not be restored, so a pool never reuses it.
|
||||
*******************************************************************************/
|
||||
private void abort() throws SQLException
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.abort(Executors.newVirtualThreadPerTaskExecutor());
|
||||
}
|
||||
catch(SQLException | RuntimeException | AbstractMethodError e)
|
||||
{
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void execute(String sql) throws SQLException
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.sql.Types;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.explain.PlanNode;
|
||||
import com.kingsrook.qbits.sqlworkbench.explain.PlanParser;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.NamedParameterSql;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlClassifier;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlStatement;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.StatementKind;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs workbench SQL: read-only sessions, the write-mode preview / commit
|
||||
** protocol, and EXPLAIN. Every run is validated, audited (RUNNING row
|
||||
** first; no audit, no run), guarded (SessionGuard), bounded (timeouts, row
|
||||
** and byte caps) and cancellable.
|
||||
**
|
||||
** SQL errors are results, not exceptions: the response has status FAILED /
|
||||
** TIMED_OUT / CANCELLED and the database's message. Exceptions are for the
|
||||
** caller's mistakes (QBadRequestException), permissions
|
||||
** (QPermissionDeniedException) and unknown targets (QNotFoundException).
|
||||
**
|
||||
** Logs never carry SQL text, parameters or results.
|
||||
*******************************************************************************/
|
||||
public class SqlExecutionEngine
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SqlExecutionEngine.class);
|
||||
|
||||
private static final java.util.regex.Pattern UUID_PATTERN = java.util.regex.Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
|
||||
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
private final WorkbenchBackends backends;
|
||||
private final ExecutionAuditor auditor;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SqlExecutionEngine(QInstance qInstance, SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.config = runtime.getConfig();
|
||||
this.backends = new WorkbenchBackends(qInstance, runtime);
|
||||
this.auditor = new ExecutionAuditor(config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The target resolver this engine uses.
|
||||
*******************************************************************************/
|
||||
public WorkbenchBackends getBackends()
|
||||
{
|
||||
return (backends);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run SQL: read-only, write preview, or write commit.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> execute(ExecutionRequest request, Caller caller) throws QException
|
||||
{
|
||||
if(request.executionUuid() != null)
|
||||
{
|
||||
if(!UUID_PATTERN.matcher(request.executionUuid()).matches())
|
||||
{
|
||||
throw (new QBadRequestException("executionUuid must be a UUID."));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// ids are single-use: a re-sent id must not reach the registry or //
|
||||
// the audit (UNIQUE) - it is refused before anything is recorded //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
String uuid = request.executionUuid().toLowerCase(Locale.ROOT);
|
||||
if(runtime.getRunningStatements().isRunning(uuid) || auditor.exists(uuid))
|
||||
{
|
||||
throw (new DuplicateExecutionException(uuid));
|
||||
}
|
||||
}
|
||||
|
||||
WorkbenchBackends.Target target = backends.getTarget(request.backendName());
|
||||
Map<String, Object> params = request.params() == null ? Map.of() : request.params();
|
||||
SqlWorkbenchExecution audit = newAudit(request, caller, params, request.write() ? ExecutionMode.WRITE_PREVIEW : ExecutionMode.READ_ONLY);
|
||||
List<SqlStatement> statements = validate(request.sql(), target.dialect(), params, audit);
|
||||
audit.setStatementCount(statements.size());
|
||||
audit.setStatementKinds(String.join(",", statements.stream().map(s -> s.kind().name()).distinct().toList()));
|
||||
|
||||
if(!request.write())
|
||||
{
|
||||
if(statements.size() != 1 || statements.get(0).kind() != StatementKind.READ)
|
||||
{
|
||||
reject(audit, "Read-only sessions run exactly one read-only statement (SELECT, WITH, VALUES, SHOW, EXPLAIN). "
|
||||
+ describeNonReads(statements) + "Use write mode for anything else.");
|
||||
}
|
||||
return (runReadOnly(target, statements.get(0), params, request, caller, audit));
|
||||
}
|
||||
|
||||
if(!caller.canWrite() || !Boolean.TRUE.equals(config.getWriteModeEnabled()))
|
||||
{
|
||||
rejectPermission(audit, "Write mode needs the " + config.getWritePermissionName() + " permission.");
|
||||
}
|
||||
if(statements.size() > config.getMaxStatementsPerRun())
|
||||
{
|
||||
reject(audit, "Too many statements (" + statements.size() + "); the limit is " + config.getMaxStatementsPerRun() + ".");
|
||||
}
|
||||
|
||||
if(!StringUtils.hasContent(request.confirmToken()))
|
||||
{
|
||||
return (runWritePreview(target, statements, params, caller, audit));
|
||||
}
|
||||
audit.setMode(ExecutionMode.WRITE_COMMIT.name());
|
||||
return (runWriteCommit(target, statements, params, request, caller, audit));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** EXPLAIN (or EXPLAIN ANALYZE) one statement.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> explain(String backendName, String sql, Map<String, Object> rawParams, boolean analyze, Caller caller) throws QException
|
||||
{
|
||||
WorkbenchBackends.Target target = backends.getTarget(backendName);
|
||||
Map<String, Object> params = rawParams == null ? Map.of() : rawParams;
|
||||
ExecutionRequest request = new ExecutionRequest(backendName, sql, params, false, null, null, null, null, false);
|
||||
SqlWorkbenchExecution audit = newAudit(request, caller, params, analyze ? ExecutionMode.EXPLAIN_ANALYZE : ExecutionMode.EXPLAIN);
|
||||
|
||||
List<SqlStatement> statements = validate(sql, target.dialect(), params, audit);
|
||||
audit.setStatementCount(statements.size());
|
||||
SqlStatement statement = statements.get(0);
|
||||
audit.setStatementKinds(statement.kind().name());
|
||||
|
||||
if(statements.size() != 1)
|
||||
{
|
||||
reject(audit, "EXPLAIN takes exactly one statement.");
|
||||
}
|
||||
if("EXPLAIN".equals(statement.leadingKeyword()))
|
||||
{
|
||||
reject(audit, "Give the statement to explain, without EXPLAIN.");
|
||||
}
|
||||
if(analyze && (!caller.canWrite() || !Boolean.TRUE.equals(config.getWriteModeEnabled())))
|
||||
{
|
||||
rejectPermission(audit, "EXPLAIN ANALYZE executes the statement, so it needs the " + config.getWritePermissionName() + " permission.");
|
||||
}
|
||||
if(statement.kind() != StatementKind.READ && !(analyze && statement.kind() == StatementKind.WRITE))
|
||||
{
|
||||
reject(audit, "Only read-only statements can be explained" + (analyze ? " (or DML with EXPLAIN ANALYZE)" : "") + ".");
|
||||
}
|
||||
if(analyze && !PlanParser.supportsAnalyze(target.dialect()))
|
||||
{
|
||||
reject(audit, "EXPLAIN ANALYZE is not supported on " + target.dialect().getUiName() + ".");
|
||||
}
|
||||
|
||||
boolean readOnly = statement.kind() == StatementKind.READ;
|
||||
int timeout = readOnly ? config.getStatementTimeoutSeconds() : config.getWriteStatementTimeoutSeconds();
|
||||
NamedParameterSql named = NamedParameterSql.of(statement);
|
||||
String explain = PlanParser.explainSql(target.dialect(), named.jdbcSql(), analyze);
|
||||
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, readOnly), target.dialect(), readOnly, timeout).begin())
|
||||
{
|
||||
CollectedResult result = runOne(guard, explain, named.parameterOrder(), params, target.dialect(), registration).result();
|
||||
guard.rollback();
|
||||
|
||||
List<List<Object>> rows = result == null ? List.of() : result.rows();
|
||||
PlanNode plan = PlanParser.parse(target.dialect(), rows, analyze);
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.SUCCEEDED, rows.size(), null, false, null));
|
||||
response.put("status", ExecutionStatus.SUCCEEDED.name());
|
||||
response.put("dialect", target.dialect().getUiName());
|
||||
response.put("analyze", analyze);
|
||||
response.put("plan", plan.toMap());
|
||||
response.put("raw", PlanParser.raw(rows));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
}
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel a running execution of this caller.
|
||||
*******************************************************************************/
|
||||
public boolean cancel(String executionUuid, Caller caller)
|
||||
{
|
||||
return (executionUuid != null && runtime.getRunningStatements().cancel(executionUuid.toLowerCase(Locale.ROOT), caller.userId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate length, split and classify, refuse FORBIDDEN, check params.
|
||||
*******************************************************************************/
|
||||
private List<SqlStatement> validate(String sql, SqlDialect dialect, Map<String, Object> params, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(sql))
|
||||
{
|
||||
throw (new QBadRequestException("There is no SQL to run."));
|
||||
}
|
||||
if(sql.length() > config.getMaxSqlLength())
|
||||
{
|
||||
reject(audit, "The SQL is longer than the limit of " + config.getMaxSqlLength() + " characters.");
|
||||
}
|
||||
|
||||
List<SqlStatement> statements = SqlClassifier.split(sql, dialect);
|
||||
if(statements.isEmpty())
|
||||
{
|
||||
throw (new QBadRequestException("There is no SQL to run (only comments)."));
|
||||
}
|
||||
|
||||
for(SqlStatement statement : statements)
|
||||
{
|
||||
if(statement.kind() == StatementKind.FORBIDDEN)
|
||||
{
|
||||
reject(audit, "Not allowed: " + statement.reason() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> missing = new LinkedHashSet<>();
|
||||
for(SqlStatement statement : statements)
|
||||
{
|
||||
for(String name : statement.parameterNames())
|
||||
{
|
||||
if(!params.containsKey(name))
|
||||
{
|
||||
missing.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!missing.isEmpty())
|
||||
{
|
||||
reject(audit, "Missing value for parameter(s): " + String.join(", ", missing.stream().map(n -> ":" + n).toList()) + ".");
|
||||
}
|
||||
return (statements);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String describeNonReads(List<SqlStatement> statements)
|
||||
{
|
||||
if(statements.size() > 1)
|
||||
{
|
||||
return ("This has " + statements.size() + " statements. ");
|
||||
}
|
||||
SqlStatement statement = statements.get(0);
|
||||
return ("This statement is " + statement.kind().name().toLowerCase() + " (" + statement.reason() + "). ");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One read-only statement.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> runReadOnly(WorkbenchBackends.Target target, SqlStatement statement, Map<String, Object> params, ExecutionRequest request, Caller caller, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
NamedParameterSql named = NamedParameterSql.of(statement);
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, true), target.dialect(), true, config.getStatementTimeoutSeconds()).begin())
|
||||
{
|
||||
StatementOutcome outcome = runOne(guard, named.jdbcSql(), named.parameterOrder(), params, target.dialect(), registration);
|
||||
guard.rollback();
|
||||
putResult(response, audit, caller, target, outcome.result(), request.pageSize());
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.SUCCEEDED, outcome.result() == null ? 0 : outcome.result().rows().size(), null,
|
||||
outcome.result() != null && outcome.result().truncated(), null));
|
||||
response.put("status", ExecutionStatus.SUCCEEDED.name());
|
||||
response.put("statements", List.of(statementView(0, statement, null)));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
}
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write preview: dry run (where possible) in a transaction that is always
|
||||
** rolled back, then a confirmation token.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> runWritePreview(WorkbenchBackends.Target target, List<SqlStatement> statements, Map<String, Object> params, Caller caller, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
boolean anyDdl = statements.stream().anyMatch(s -> s.kind() == StatementKind.DDL);
|
||||
boolean dryRunSupported = !anyDdl || target.dialect().isTransactionalDdl();
|
||||
String sqlHash = audit.getSqlHash();
|
||||
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
List<Map<String, Object>> views = new ArrayList<>();
|
||||
Long total = null;
|
||||
|
||||
if(dryRunSupported)
|
||||
{
|
||||
total = 0L;
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, false), target.dialect(), false, config.getWriteStatementTimeoutSeconds()).begin())
|
||||
{
|
||||
for(int i = 0; i < statements.size(); i++)
|
||||
{
|
||||
NamedParameterSql named = NamedParameterSql.of(statements.get(i));
|
||||
StatementOutcome outcome = runOne(guard, named.jdbcSql(), named.parameterOrder(), params, target.dialect(), registration);
|
||||
long affected = outcome.affectedRows();
|
||||
total += affected;
|
||||
views.add(statementView(i, statements.get(i), affected));
|
||||
}
|
||||
guard.rollback();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
return (response);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for(int i = 0; i < statements.size(); i++)
|
||||
{
|
||||
views.add(statementView(i, statements.get(i), null));
|
||||
}
|
||||
}
|
||||
|
||||
boolean rowGuardExceeded = total != null && total > config.getMaxAffectedRowsWithoutAcknowledgement();
|
||||
String token = runtime.getWritePreviewTokens().issue(caller.userId(), target.name(), sqlHash, total, rowGuardExceeded);
|
||||
|
||||
finish(audit, dryRunSupported ? ExecutionStatus.ROLLED_BACK : ExecutionStatus.SUCCEEDED, null, total, false, null);
|
||||
response.put("status", "PREVIEW");
|
||||
response.put("statements", views);
|
||||
response.put("dryRunSupported", dryRunSupported);
|
||||
response.put("dryRunNote", dryRunSupported ? null : "DDL on " + target.dialect().getUiName() + " commits implicitly, so it cannot be dry-run.");
|
||||
response.put("totalAffectedRows", total);
|
||||
response.put("rowGuardExceeded", rowGuardExceeded);
|
||||
response.put("rowGuardLimit", config.getMaxAffectedRowsWithoutAcknowledgement());
|
||||
response.put("confirmToken", token);
|
||||
response.put("expiresAt", String.valueOf(runtime.getWritePreviewTokens().expiresAt(token)));
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write commit: check the token, re-run in one transaction, compare with
|
||||
** the preview, enforce the hard cap, commit.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> runWriteCommit(WorkbenchBackends.Target target, List<SqlStatement> statements, Map<String, Object> params, ExecutionRequest request, Caller caller, SqlWorkbenchExecution audit) throws QException
|
||||
{
|
||||
WritePreviewTokens tokens = runtime.getWritePreviewTokens();
|
||||
WritePreviewTokens.Preview preview = tokens.find(request.confirmToken(), caller.userId(), target.name(), audit.getSqlHash());
|
||||
if(preview == null)
|
||||
{
|
||||
reject(audit, "The write confirmation is missing, expired, or does not match this SQL; preview it again.");
|
||||
}
|
||||
if(preview.rowGuardExceeded() && !request.acknowledgeRowGuard())
|
||||
{
|
||||
reject(audit, "This write affects more than " + config.getMaxAffectedRowsWithoutAcknowledgement() + " rows; acknowledge the row guard to commit.");
|
||||
}
|
||||
if(!tokens.consume(request.confirmToken()))
|
||||
{
|
||||
reject(audit, "This write confirmation was already used; preview it again.");
|
||||
}
|
||||
|
||||
return (withRegistration(audit, caller, (registration, deferred) ->
|
||||
{
|
||||
Map<String, Object> response = baseResponse(audit);
|
||||
List<Map<String, Object>> views = new ArrayList<>();
|
||||
long total = 0;
|
||||
CollectedResult last = null;
|
||||
|
||||
try(SessionGuard guard = new SessionGuard(backends.openConnection(target, false), target.dialect(), false, config.getWriteStatementTimeoutSeconds()).begin())
|
||||
{
|
||||
for(int i = 0; i < statements.size(); i++)
|
||||
{
|
||||
NamedParameterSql named = NamedParameterSql.of(statements.get(i));
|
||||
StatementOutcome outcome = runOne(guard, named.jdbcSql(), named.parameterOrder(), params, target.dialect(), registration);
|
||||
total += outcome.affectedRows();
|
||||
if(outcome.result() != null)
|
||||
{
|
||||
last = outcome.result();
|
||||
}
|
||||
views.add(statementView(i, statements.get(i), outcome.affectedRows()));
|
||||
}
|
||||
|
||||
long finalTotal = total;
|
||||
if(preview.affectedRows() != null && preview.affectedRows() != total)
|
||||
{
|
||||
guard.rollback();
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.DRIFTED, null, finalTotal, false, "Affected rows changed since the preview (" + preview.affectedRows() + " -> " + finalTotal + ")"));
|
||||
response.put("status", ExecutionStatus.DRIFTED.name());
|
||||
response.put("previewAffectedRows", preview.affectedRows());
|
||||
response.put("actualAffectedRows", total);
|
||||
response.put("statements", views);
|
||||
return (response);
|
||||
}
|
||||
|
||||
if(total > config.getMaxAffectedRows())
|
||||
{
|
||||
guard.rollback();
|
||||
deferred.set(() -> finish(audit, ExecutionStatus.REJECTED, null, finalTotal, false, "Affected rows exceed the hard limit of " + config.getMaxAffectedRows()));
|
||||
response.put("status", ExecutionStatus.REJECTED.name());
|
||||
response.put("error", "This write affects " + total + " rows, more than the limit of " + config.getMaxAffectedRows() + "; it was rolled back.");
|
||||
response.put("statements", views);
|
||||
return (response);
|
||||
}
|
||||
|
||||
guard.commit();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
sqlFailure(audit, response, e, registration);
|
||||
return (response);
|
||||
}
|
||||
|
||||
putResult(response, audit, caller, target, last, request.pageSize());
|
||||
finish(audit, ExecutionStatus.SUCCEEDED, last == null ? null : last.rows().size(), total, last != null && last.truncated(), null);
|
||||
response.put("status", ExecutionStatus.SUCCEEDED.name());
|
||||
response.put("statements", views);
|
||||
response.put("rowsAffected", total);
|
||||
return (response);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of one statement.
|
||||
**
|
||||
** @param result its result set, if it returned one
|
||||
** @param updateCount its update count (-1 when it returned rows)
|
||||
*******************************************************************************/
|
||||
record StatementOutcome(CollectedResult result, long updateCount)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows this statement changed (for result-returning statements, e.g.
|
||||
** INSERT ... RETURNING, the rows returned).
|
||||
*******************************************************************************/
|
||||
long affectedRows()
|
||||
{
|
||||
if(updateCount >= 0)
|
||||
{
|
||||
return (updateCount);
|
||||
}
|
||||
return (result == null ? 0 : result.rows().size());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prepare, bind, run and collect one statement.
|
||||
*******************************************************************************/
|
||||
private StatementOutcome runOne(SessionGuard guard, String jdbcSql, List<String> parameterOrder, Map<String, Object> params, SqlDialect dialect, RunningStatements.Running registration) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = guard.getConnection().prepareStatement(jdbcSql))
|
||||
{
|
||||
guard.configure(statement, config.getMaxRows() + 1, config.getFetchSize());
|
||||
for(int i = 0; i < parameterOrder.size(); i++)
|
||||
{
|
||||
bind(statement, i + 1, params.get(parameterOrder.get(i)), dialect);
|
||||
}
|
||||
|
||||
registration.setStatement(statement);
|
||||
try
|
||||
{
|
||||
boolean hasResultSet = statement.execute();
|
||||
if(hasResultSet)
|
||||
{
|
||||
try(ResultSet resultSet = statement.getResultSet())
|
||||
{
|
||||
ResultCollector collector = new ResultCollector(config.getMaxRows(), config.getMaxResultBytes(), config.getMaxCellChars());
|
||||
return (new StatementOutcome(collector.collect(resultSet), -1));
|
||||
}
|
||||
}
|
||||
return (new StatementOutcome(null, Math.max(0, statement.getUpdateCount())));
|
||||
}
|
||||
finally
|
||||
{
|
||||
registration.setStatement(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind one parameter value. Strings on Postgres are bound untyped
|
||||
** (Types.OTHER) so the server infers their type from context (dates,
|
||||
** numbers, uuids); everywhere else they are strings, which the other
|
||||
** databases coerce.
|
||||
*******************************************************************************/
|
||||
static void bind(PreparedStatement statement, int index, Object value, SqlDialect dialect) throws SQLException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
statement.setNull(index, dialect == SqlDialect.POSTGRES ? Types.OTHER : Types.NULL);
|
||||
}
|
||||
else if(value instanceof Boolean b)
|
||||
{
|
||||
statement.setBoolean(index, b);
|
||||
}
|
||||
else if(value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte || value instanceof BigInteger)
|
||||
{
|
||||
statement.setLong(index, ((Number) value).longValue());
|
||||
}
|
||||
else if(value instanceof BigDecimal bd)
|
||||
{
|
||||
statement.setBigDecimal(index, bd);
|
||||
}
|
||||
else if(value instanceof Number n)
|
||||
{
|
||||
statement.setBigDecimal(index, new BigDecimal(n.toString()));
|
||||
}
|
||||
else if(dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
statement.setObject(index, value.toString(), Types.OTHER);
|
||||
}
|
||||
else
|
||||
{
|
||||
statement.setString(index, value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cache the result and put its first page in the response.
|
||||
*******************************************************************************/
|
||||
private void putResult(Map<String, Object> response, SqlWorkbenchExecution audit, Caller caller, WorkbenchBackends.Target target, CollectedResult result, Integer pageSize)
|
||||
{
|
||||
if(result == null)
|
||||
{
|
||||
response.put("columns", List.of());
|
||||
response.put("rows", List.of());
|
||||
response.put("rowCount", 0);
|
||||
response.put("hasMore", false);
|
||||
response.put("truncated", false);
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.getResultCache().put(audit.getExecutionUuid(), caller.userId(), target.name(), result);
|
||||
int size = pageSize == null || pageSize <= 0 ? config.getDefaultPageSize() : Math.min(pageSize, config.getMaxPageSize());
|
||||
response.put("columns", result.columns().stream().map(ColumnInfo::toMap).toList());
|
||||
response.put("rows", ResultCache.page(result, 0, size, null, false));
|
||||
response.put("rowCount", result.rows().size());
|
||||
response.put("hasMore", result.rows().size() > size);
|
||||
response.put("truncated", result.truncated());
|
||||
response.put("truncatedReason", result.truncatedReason());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> statementView(int index, SqlStatement statement, Long affectedRows)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("index", index);
|
||||
view.put("kind", statement.kind().name());
|
||||
view.put("leadingKeyword", statement.leadingKeyword());
|
||||
view.put("text", statement.text().length() > 500 ? statement.text().substring(0, 500) + "…" : statement.text());
|
||||
view.put("affectedRows", affectedRows);
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> baseResponse(SqlWorkbenchExecution audit)
|
||||
{
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("executionUuid", audit.getExecutionUuid());
|
||||
response.put("mode", audit.getMode());
|
||||
response.put("backendName", audit.getBackendName());
|
||||
return (response);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record a SQL failure in the audit and the response.
|
||||
*******************************************************************************/
|
||||
private void sqlFailure(SqlWorkbenchExecution audit, Map<String, Object> response, SQLException e, RunningStatements.Running registration) throws QException
|
||||
{
|
||||
ExecutionStatus status;
|
||||
if(registration.isCancelled())
|
||||
{
|
||||
status = ExecutionStatus.CANCELLED;
|
||||
}
|
||||
else if(e instanceof SQLTimeoutException || "57014".equals(e.getSQLState()))
|
||||
{
|
||||
status = ExecutionStatus.TIMED_OUT;
|
||||
}
|
||||
else
|
||||
{
|
||||
status = ExecutionStatus.FAILED;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// a failure to connect is not the caller's SQL: its driver message //
|
||||
// can expose hosts / users / URLs, so it stays in the server log //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
String message = e.getMessage();
|
||||
if(e instanceof WorkbenchBackends.ConnectionFailedException)
|
||||
{
|
||||
LOG.warn("SQL Workbench could not open a connection", e.getCause(), logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()));
|
||||
}
|
||||
|
||||
finish(audit, status, null, null, false, message);
|
||||
response.put("status", status.name());
|
||||
response.put("error", message);
|
||||
response.put("sqlState", e.getSQLState());
|
||||
LOG.info("SQL Workbench execution did not succeed", logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()),
|
||||
logPair("mode", audit.getMode()), logPair("status", status), logPair("sqlState", e.getSQLState()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register (concurrency limit), audit-start, run, and always unregister.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> withRegistration(SqlWorkbenchExecution audit, Caller caller, RegisteredRun run) throws QException
|
||||
{
|
||||
RunningStatements.Running registration = runtime.getRunningStatements().register(audit.getExecutionUuid(), caller.userId(), config.getMaxConcurrentExecutionsPerUser());
|
||||
if(registration == null)
|
||||
{
|
||||
reject(audit, "You already have " + config.getMaxConcurrentExecutionsPerUser() + " executions running; wait for one to finish or cancel it.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
auditor.start(audit);
|
||||
Deferred deferred = new Deferred();
|
||||
Map<String, Object> response;
|
||||
try
|
||||
{
|
||||
response = run.run(registration, deferred);
|
||||
deferred.run();
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
failUnfinished(audit, e);
|
||||
throw (e);
|
||||
}
|
||||
LOG.info("SQL Workbench execution", logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()),
|
||||
logPair("mode", audit.getMode()), logPair("status", audit.getStatus()), logPair("durationMs", audit.getDurationMs()));
|
||||
response.put("durationMs", audit.getDurationMs());
|
||||
return (response);
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.getRunningStatements().unregister(audit.getExecutionUuid(), registration);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unexpected exception escaped a started run: if its audit row is still
|
||||
** RUNNING, finish it as FAILED (with a generic message; the detail goes to
|
||||
** the server log). A failure to do so is logged, never masks the original.
|
||||
*******************************************************************************/
|
||||
private void failUnfinished(SqlWorkbenchExecution audit, Exception e)
|
||||
{
|
||||
LOG.warn("SQL Workbench execution failed unexpectedly", e, logPair("executionUuid", audit.getExecutionUuid()), logPair("backendName", audit.getBackendName()));
|
||||
if(audit.getId() == null || !ExecutionStatus.RUNNING.name().equals(audit.getStatus()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
finish(audit, ExecutionStatus.FAILED, null, null, false, "The execution failed unexpectedly; see the server log.");
|
||||
}
|
||||
catch(Exception finishFailure)
|
||||
{
|
||||
LOG.warn("Could not finish the SQL Workbench audit record", finishFailure, logPair("executionUuid", audit.getExecutionUuid()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run with its registration, and a place to defer the audit update until
|
||||
** the session's connection is released (so an audit write never needs a
|
||||
** second connection from a pool the session is holding).
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface RegisteredRun
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Map<String, Object> run(RunningStatements.Running registration, Deferred deferred) throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One deferred audit update.
|
||||
*******************************************************************************/
|
||||
private static class Deferred
|
||||
{
|
||||
private SystemUser.Block action;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void set(SystemUser.Block action)
|
||||
{
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
void run() throws QException
|
||||
{
|
||||
if(action != null)
|
||||
{
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void finish(SqlWorkbenchExecution audit, ExecutionStatus status, Integer rowsReturned, Long rowsAffected, Boolean truncated, String error) throws QException
|
||||
{
|
||||
auditor.finish(audit, status, runtime.getClock().instant(), rowsReturned, rowsAffected, truncated, error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit a rejected request, then throw QBadRequestException.
|
||||
*******************************************************************************/
|
||||
private void reject(SqlWorkbenchExecution audit, String message) throws QException
|
||||
{
|
||||
recordRejection(audit, message);
|
||||
throw (new QBadRequestException(message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit a permission refusal, then throw QPermissionDeniedException.
|
||||
*******************************************************************************/
|
||||
private void rejectPermission(SqlWorkbenchExecution audit, String message) throws QException
|
||||
{
|
||||
recordRejection(audit, message);
|
||||
throw (new QPermissionDeniedException(message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void recordRejection(SqlWorkbenchExecution audit, String message) throws QException
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
audit.setStatus(ExecutionStatus.REJECTED.name());
|
||||
audit.setEndTime(now);
|
||||
audit.setDurationMs(0);
|
||||
audit.setErrorMessage(message);
|
||||
auditor.record(audit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A new audit record for a request.
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchExecution newAudit(ExecutionRequest request, Caller caller, Map<String, Object> params, ExecutionMode mode)
|
||||
{
|
||||
return (new SqlWorkbenchExecution()
|
||||
.withExecutionUuid(request.executionUuid() != null ? request.executionUuid().toLowerCase(Locale.ROOT) : UUID.randomUUID().toString())
|
||||
.withUserId(caller.userId())
|
||||
.withUserName(caller.userName())
|
||||
.withBackendName(request.backendName())
|
||||
.withMode(mode.name())
|
||||
.withSqlText(request.sql())
|
||||
.withSqlHash(sqlHash(request.backendName(), request.sql(), params))
|
||||
.withParametersJson(params.isEmpty() ? null : JsonUtils.toJson(new TreeMap<>(params)))
|
||||
.withNotebookId(request.notebookId())
|
||||
.withCellKey(request.cellKey())
|
||||
.withClientIp(caller.clientIp())
|
||||
.withStartTime(runtime.getClock().instant()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of backend, SQL and parameters: binds a write confirmation to
|
||||
** exactly what was previewed.
|
||||
*******************************************************************************/
|
||||
static String sqlHash(String backendName, String sql, Map<String, Object> params)
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
String input = backendName + "\u0000" + sql + "\u0000" + JsonUtils.toJson(new TreeMap<>(params == null ? Map.of() : params));
|
||||
return (HexFormat.of().formatHex(digest.digest(input.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchCredentials;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves the workbench's target backends (the instance's RDBMS backends,
|
||||
** filtered by the config's allow / deny lists) and opens connections to
|
||||
** them through QQQ's ConnectionManager.
|
||||
**
|
||||
** Connection source, read-only sessions: readOnlyCredentials for the backend
|
||||
** (a derived backend meta-data with its own pool), else the backend's
|
||||
** readOnlyBackendMetaData when useBackendReadOnlyReplica, else the backend.
|
||||
** Write sessions: writeCredentials for the backend, else the backend.
|
||||
*******************************************************************************/
|
||||
public class WorkbenchBackends
|
||||
{
|
||||
public static final String READ_ONLY_SUFFIX = "__sqlWorkbenchReadOnly";
|
||||
public static final String WRITE_SUFFIX = "__sqlWorkbenchWrite";
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A target backend.
|
||||
**
|
||||
** @param backend the instance's backend meta-data
|
||||
** @param dialect its dialect
|
||||
*******************************************************************************/
|
||||
public record Target(RDBMSBackendMetaData backend, SqlDialect dialect)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend's name.
|
||||
*******************************************************************************/
|
||||
public String name()
|
||||
{
|
||||
return (backend.getName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WorkbenchBackends(QInstance qInstance, SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every target backend, in instance order.
|
||||
*******************************************************************************/
|
||||
public List<Target> listTargets()
|
||||
{
|
||||
List<Target> targets = new ArrayList<>();
|
||||
for(QBackendMetaData backend : qInstance.getBackends().values())
|
||||
{
|
||||
if(backend instanceof RDBMSBackendMetaData rdbms && isAllowed(rdbms.getName()))
|
||||
{
|
||||
targets.add(new Target(rdbms, SqlDialect.fromVendor(rdbms.getVendor())));
|
||||
}
|
||||
}
|
||||
return (targets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One target, or QNotFoundException for an unknown, non-RDBMS or excluded
|
||||
** backend.
|
||||
*******************************************************************************/
|
||||
public Target getTarget(String backendName) throws QNotFoundException
|
||||
{
|
||||
QBackendMetaData backend = StringUtils.hasContent(backendName) ? qInstance.getBackend(backendName) : null;
|
||||
if(backend instanceof RDBMSBackendMetaData rdbms && isAllowed(backendName))
|
||||
{
|
||||
return (new Target(rdbms, SqlDialect.fromVendor(rdbms.getVendor())));
|
||||
}
|
||||
throw (new QNotFoundException("Unknown SQL Workbench backend: " + backendName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private boolean isAllowed(String backendName)
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = runtime.getConfig();
|
||||
if(backendName == null || backendName.endsWith(READ_ONLY_SUFFIX) || backendName.endsWith(WRITE_SUFFIX))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(config.getExcludedBackendNames() != null && config.getExcludedBackendNames().contains(backendName))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (config.getAllowedBackendNames() == null || config.getAllowedBackendNames().contains(backendName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether read-only sessions on this target use dedicated credentials.
|
||||
*******************************************************************************/
|
||||
public boolean hasReadOnlyCredentials(Target target)
|
||||
{
|
||||
Map<String, SqlWorkbenchCredentials> credentials = runtime.getConfig().getReadOnlyCredentials();
|
||||
return (credentials != null && credentials.containsKey(target.name()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend meta-data a session connects with.
|
||||
*******************************************************************************/
|
||||
public RDBMSBackendMetaData connectionSource(Target target, boolean readOnly)
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = runtime.getConfig();
|
||||
if(readOnly)
|
||||
{
|
||||
SqlWorkbenchCredentials credentials = config.getReadOnlyCredentials() == null ? null : config.getReadOnlyCredentials().get(target.name());
|
||||
if(credentials != null)
|
||||
{
|
||||
return (derive(target.backend(), credentials, READ_ONLY_SUFFIX));
|
||||
}
|
||||
if(Boolean.TRUE.equals(config.getUseBackendReadOnlyReplica()) && target.backend().getReadOnlyBackendMetaData() != null)
|
||||
{
|
||||
return (target.backend().getReadOnlyBackendMetaData());
|
||||
}
|
||||
return (target.backend());
|
||||
}
|
||||
|
||||
SqlWorkbenchCredentials credentials = config.getWriteCredentials() == null ? null : config.getWriteCredentials().get(target.name());
|
||||
return (credentials == null ? target.backend() : derive(target.backend(), credentials, WRITE_SUFFIX));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a connection for a session.
|
||||
*******************************************************************************/
|
||||
public Connection openConnection(Target target, boolean readOnly) throws SQLException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (ConnectionManager.getConnection(connectionSource(target, readOnly)));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (new ConnectionFailedException(target.name(), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The connection to a target could not be opened. Its driver message may
|
||||
** carry hosts, user names or URL details, so callers show a generic
|
||||
** message (getMessage) and log the cause server-side.
|
||||
*******************************************************************************/
|
||||
public static class ConnectionFailedException extends SQLException
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public ConnectionFailedException(String backendName, SQLException cause)
|
||||
{
|
||||
super("Could not connect to backend " + backendName + "; see the server log.", (String) null, cause);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A copy of a backend's connection settings with other credentials, under a
|
||||
** derived name (so it gets its own connection provider / pool). Built once
|
||||
** per backend and suffix.
|
||||
*******************************************************************************/
|
||||
RDBMSBackendMetaData derive(RDBMSBackendMetaData original, SqlWorkbenchCredentials credentials, String suffix)
|
||||
{
|
||||
String name = original.getName() + suffix;
|
||||
return (runtime.getDerivedBackends().computeIfAbsent(name, n ->
|
||||
{
|
||||
QMetaDataVariableInterpreter interpreter = new QMetaDataVariableInterpreter();
|
||||
RDBMSBackendMetaData derived = new RDBMSBackendMetaData();
|
||||
derived.setName(n);
|
||||
derived.setVendor(original.getVendor());
|
||||
derived.setHostName(original.getHostName());
|
||||
derived.setPort(original.getPort());
|
||||
derived.setDatabaseName(original.getDatabaseName());
|
||||
derived.setJdbcUrl(StringUtils.hasContent(credentials.getJdbcUrl()) ? interpreter.interpret(credentials.getJdbcUrl()) : ConnectionManager.getJdbcUrl(original));
|
||||
derived.setJdbcDriverClassName(ConnectionManager.getJdbcDriverClassName(original));
|
||||
derived.setConnectionProvider(original.getConnectionProvider());
|
||||
derived.setConnectionPoolSettings(original.getConnectionPoolSettings());
|
||||
derived.setQueriesForNewConnections(original.getQueriesForNewConnections());
|
||||
derived.setUsername(interpreter.interpret(credentials.getUsername()));
|
||||
derived.setPassword(interpreter.interpret(credentials.getPassword()));
|
||||
return (derived);
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Single-use confirmation tokens for write mode. A preview issues a token
|
||||
** bound to the user, backend and SQL hash (SQL plus parameters); a commit
|
||||
** must present it before it expires. Tokens live only in memory, so a
|
||||
** restart forces a new preview.
|
||||
*******************************************************************************/
|
||||
public class WritePreviewTokens
|
||||
{
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final Map<String, Preview> previews = new ConcurrentHashMap<>();
|
||||
private final long ttlMillis;
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a preview found.
|
||||
**
|
||||
** @param userId who previewed
|
||||
** @param backendName where
|
||||
** @param sqlHash hash of SQL plus parameters
|
||||
** @param affectedRows total rows the dry run affected (null when no
|
||||
** dry run was possible)
|
||||
** @param rowGuardExceeded whether affectedRows exceeded the guard
|
||||
** @param expiresAt when the token stops working
|
||||
*******************************************************************************/
|
||||
public record Preview(String userId, String backendName, String sqlHash, Long affectedRows, boolean rowGuardExceeded, Instant expiresAt)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WritePreviewTokens(int ttlSeconds, Clock clock)
|
||||
{
|
||||
this.ttlMillis = ttlSeconds * 1000L;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Issue a token for a preview.
|
||||
*******************************************************************************/
|
||||
public String issue(String userId, String backendName, String sqlHash, Long affectedRows, boolean rowGuardExceeded)
|
||||
{
|
||||
purgeExpired();
|
||||
byte[] bytes = new byte[24];
|
||||
RANDOM.nextBytes(bytes);
|
||||
String token = HexFormat.of().formatHex(bytes);
|
||||
previews.put(token, new Preview(userId, backendName, sqlHash, affectedRows, rowGuardExceeded, clock.instant().plusMillis(ttlMillis)));
|
||||
return (token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** When the token expires (for responses), or null for an unknown token.
|
||||
*******************************************************************************/
|
||||
public Instant expiresAt(String token)
|
||||
{
|
||||
Preview preview = previews.get(token);
|
||||
return (preview == null ? null : preview.expiresAt());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look up a token without consuming it.
|
||||
**
|
||||
** @return the preview, or null when the token is unknown, expired, or bound
|
||||
** to a different user, backend or SQL
|
||||
*******************************************************************************/
|
||||
public Preview find(String token, String userId, String backendName, String sqlHash)
|
||||
{
|
||||
if(token == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Preview preview = previews.get(token);
|
||||
if(preview == null || preview.expiresAt().isBefore(clock.instant()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(!Objects.equals(preview.userId(), userId) || !Objects.equals(preview.backendName(), backendName) || !Objects.equals(preview.sqlHash(), sqlHash))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (preview);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Consume a token: it can never be used again.
|
||||
**
|
||||
** @return whether this call removed it (false if another commit won)
|
||||
*******************************************************************************/
|
||||
public boolean consume(String token)
|
||||
{
|
||||
return (token != null && previews.remove(token) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void purgeExpired()
|
||||
{
|
||||
Instant now = clock.instant();
|
||||
previews.values().removeIf(p -> p.expiresAt().isBefore(now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public int size()
|
||||
{
|
||||
purgeExpired();
|
||||
return (previews.size());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.explain;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One node of a normalized query plan. Numeric fields are null when the
|
||||
** database does not report them (e.g. actual* without ANALYZE).
|
||||
*******************************************************************************/
|
||||
public class PlanNode
|
||||
{
|
||||
private String label;
|
||||
private String detail;
|
||||
private Double startupCost;
|
||||
private Double totalCost;
|
||||
private Double planRows;
|
||||
private Double actualTimeMs;
|
||||
private Double actualRows;
|
||||
private Double loops;
|
||||
private Map<String, Object> extra = new LinkedHashMap<>();
|
||||
private List<PlanNode> children = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public PlanNode()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public PlanNode(String label, String detail)
|
||||
{
|
||||
this.label = label;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON view of this node and its children.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> toMap()
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", label);
|
||||
map.put("detail", detail);
|
||||
map.put("startupCost", startupCost);
|
||||
map.put("totalCost", totalCost);
|
||||
map.put("planRows", planRows);
|
||||
map.put("actualTimeMs", actualTimeMs);
|
||||
map.put("actualRows", actualRows);
|
||||
map.put("loops", loops);
|
||||
map.put("extra", extra);
|
||||
map.put("children", children.stream().map(PlanNode::toMap).toList());
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent child adder.
|
||||
*******************************************************************************/
|
||||
public PlanNode withChild(PlanNode child)
|
||||
{
|
||||
children.add(child);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for detail
|
||||
*******************************************************************************/
|
||||
public String getDetail()
|
||||
{
|
||||
return (detail);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for detail
|
||||
*******************************************************************************/
|
||||
public void setDetail(String detail)
|
||||
{
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startupCost
|
||||
*******************************************************************************/
|
||||
public Double getStartupCost()
|
||||
{
|
||||
return (startupCost);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for startupCost
|
||||
*******************************************************************************/
|
||||
public void setStartupCost(Double startupCost)
|
||||
{
|
||||
this.startupCost = startupCost;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for totalCost
|
||||
*******************************************************************************/
|
||||
public Double getTotalCost()
|
||||
{
|
||||
return (totalCost);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for totalCost
|
||||
*******************************************************************************/
|
||||
public void setTotalCost(Double totalCost)
|
||||
{
|
||||
this.totalCost = totalCost;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for planRows
|
||||
*******************************************************************************/
|
||||
public Double getPlanRows()
|
||||
{
|
||||
return (planRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for planRows
|
||||
*******************************************************************************/
|
||||
public void setPlanRows(Double planRows)
|
||||
{
|
||||
this.planRows = planRows;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for actualTimeMs
|
||||
*******************************************************************************/
|
||||
public Double getActualTimeMs()
|
||||
{
|
||||
return (actualTimeMs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for actualTimeMs
|
||||
*******************************************************************************/
|
||||
public void setActualTimeMs(Double actualTimeMs)
|
||||
{
|
||||
this.actualTimeMs = actualTimeMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for actualRows
|
||||
*******************************************************************************/
|
||||
public Double getActualRows()
|
||||
{
|
||||
return (actualRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for actualRows
|
||||
*******************************************************************************/
|
||||
public void setActualRows(Double actualRows)
|
||||
{
|
||||
this.actualRows = actualRows;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for loops
|
||||
*******************************************************************************/
|
||||
public Double getLoops()
|
||||
{
|
||||
return (loops);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for loops
|
||||
*******************************************************************************/
|
||||
public void setLoops(Double loops)
|
||||
{
|
||||
this.loops = loops;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for extra
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> getExtra()
|
||||
{
|
||||
return (extra);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for children
|
||||
*******************************************************************************/
|
||||
public List<PlanNode> getChildren()
|
||||
{
|
||||
return (children);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.explain;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds EXPLAIN commands per dialect and normalizes their output into a
|
||||
** PlanNode tree.
|
||||
**
|
||||
** Postgres: EXPLAIN (FORMAT JSON[, ANALYZE, BUFFERS]) - the JSON "Plan" tree.
|
||||
** MySQL: EXPLAIN FORMAT=JSON (query_block, best effort) or EXPLAIN
|
||||
** ANALYZE (text tree).
|
||||
** SQLite: EXPLAIN QUERY PLAN - (id, parent, notused, detail) rows.
|
||||
** H2 / other: EXPLAIN [ANALYZE] - text, as one node per line.
|
||||
*******************************************************************************/
|
||||
public class PlanParser
|
||||
{
|
||||
private static final Set<String> POSTGRES_KNOWN_KEYS = Set.of("Node Type", "Plans", "Startup Cost", "Total Cost", "Plan Rows", "Actual Total Time",
|
||||
"Actual Rows", "Actual Loops", "Relation Name", "Alias", "Index Name", "Join Type", "Strategy", "Parent Relationship");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The EXPLAIN command for a statement.
|
||||
*******************************************************************************/
|
||||
public static String explainSql(SqlDialect dialect, String sql, boolean analyze)
|
||||
{
|
||||
return switch(dialect)
|
||||
{
|
||||
case POSTGRES -> analyze ? "EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) " + sql : "EXPLAIN (FORMAT JSON) " + sql;
|
||||
case MYSQL -> analyze ? "EXPLAIN ANALYZE " + sql : "EXPLAIN FORMAT=JSON " + sql;
|
||||
case SQLITE -> "EXPLAIN QUERY PLAN " + sql;
|
||||
default -> analyze ? "EXPLAIN ANALYZE " + sql : "EXPLAIN " + sql;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this dialect can EXPLAIN ANALYZE.
|
||||
*******************************************************************************/
|
||||
public static boolean supportsAnalyze(SqlDialect dialect)
|
||||
{
|
||||
return (dialect != SqlDialect.SQLITE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the EXPLAIN result rows into a tree.
|
||||
**
|
||||
** @param rows the EXPLAIN output (converted values, one list per row)
|
||||
*******************************************************************************/
|
||||
public static PlanNode parse(SqlDialect dialect, List<List<Object>> rows, boolean analyze)
|
||||
{
|
||||
if(rows == null || rows.isEmpty())
|
||||
{
|
||||
return (new PlanNode("(empty plan)", null));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return switch(dialect)
|
||||
{
|
||||
case POSTGRES -> parsePostgres(firstCell(rows));
|
||||
case MYSQL -> analyze ? parseText(rows) : parseMySqlJson(firstCell(rows));
|
||||
case SQLITE -> parseSqlite(rows);
|
||||
default -> parseText(rows);
|
||||
};
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// unexpected shape: still show the raw plan as text //
|
||||
////////////////////////////////////////////////////////
|
||||
return (parseText(rows));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The raw plan text, for the "raw" toggle.
|
||||
*******************************************************************************/
|
||||
public static String raw(List<List<Object>> rows)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(List<Object> row : rows == null ? List.<List<Object>>of() : rows)
|
||||
{
|
||||
if(sb.length() > 0)
|
||||
{
|
||||
sb.append('\n');
|
||||
}
|
||||
sb.append(String.join(" | ", row.stream().map(String::valueOf).toList()));
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String firstCell(List<List<Object>> rows)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(List<Object> row : rows)
|
||||
{
|
||||
sb.append(row.isEmpty() ? "" : String.valueOf(row.get(0)));
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Postgres FORMAT JSON: [ { "Plan": {...}, "Planning Time": n, ... } ]
|
||||
*******************************************************************************/
|
||||
static PlanNode parsePostgres(String json)
|
||||
{
|
||||
JSONArray array = new JSONArray(json.trim());
|
||||
JSONObject top = array.getJSONObject(0);
|
||||
PlanNode root = postgresNode(top.getJSONObject("Plan"));
|
||||
for(String key : List.of("Planning Time", "Execution Time"))
|
||||
{
|
||||
if(top.has(key))
|
||||
{
|
||||
root.getExtra().put(key, top.get(key));
|
||||
}
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static PlanNode postgresNode(JSONObject plan)
|
||||
{
|
||||
String label = plan.optString("Node Type", "?");
|
||||
StringBuilder detail = new StringBuilder();
|
||||
if(plan.has("Relation Name"))
|
||||
{
|
||||
detail.append("on ").append(plan.getString("Relation Name"));
|
||||
if(plan.has("Alias") && !plan.getString("Alias").equals(plan.getString("Relation Name")))
|
||||
{
|
||||
detail.append(' ').append(plan.getString("Alias"));
|
||||
}
|
||||
}
|
||||
if(plan.has("Index Name"))
|
||||
{
|
||||
detail.append(detail.length() > 0 ? " " : "").append("using ").append(plan.getString("Index Name"));
|
||||
}
|
||||
if(plan.has("Join Type"))
|
||||
{
|
||||
label = plan.getString("Join Type") + " " + label;
|
||||
}
|
||||
|
||||
PlanNode node = new PlanNode(label, detail.length() == 0 ? null : detail.toString());
|
||||
node.setStartupCost(optDouble(plan, "Startup Cost"));
|
||||
node.setTotalCost(optDouble(plan, "Total Cost"));
|
||||
node.setPlanRows(optDouble(plan, "Plan Rows"));
|
||||
node.setActualTimeMs(optDouble(plan, "Actual Total Time"));
|
||||
node.setActualRows(optDouble(plan, "Actual Rows"));
|
||||
node.setLoops(optDouble(plan, "Actual Loops"));
|
||||
|
||||
Iterator<String> keys = plan.keys();
|
||||
while(keys.hasNext())
|
||||
{
|
||||
String key = keys.next();
|
||||
if(!POSTGRES_KNOWN_KEYS.contains(key))
|
||||
{
|
||||
Object value = plan.get(key);
|
||||
node.getExtra().put(key, value instanceof JSONArray || value instanceof JSONObject ? value.toString() : value);
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray children = plan.optJSONArray("Plans");
|
||||
for(int i = 0; children != null && i < children.length(); i++)
|
||||
{
|
||||
node.withChild(postgresNode(children.getJSONObject(i)));
|
||||
}
|
||||
return (node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MySQL FORMAT=JSON: walk query_block, turning each object that names a
|
||||
** table or an operation into a node.
|
||||
*******************************************************************************/
|
||||
static PlanNode parseMySqlJson(String json)
|
||||
{
|
||||
JSONObject top = new JSONObject(json.trim());
|
||||
JSONObject block = top.optJSONObject("query_block");
|
||||
PlanNode root = new PlanNode("query_block", block == null ? null : "select #" + block.opt("select_id"));
|
||||
if(block != null)
|
||||
{
|
||||
JSONObject costInfo = block.optJSONObject("cost_info");
|
||||
if(costInfo != null)
|
||||
{
|
||||
root.setTotalCost(parseDouble(costInfo.opt("query_cost")));
|
||||
}
|
||||
mysqlChildren(block, root);
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void mysqlChildren(JSONObject object, PlanNode parent)
|
||||
{
|
||||
Iterator<String> keys = object.keys();
|
||||
while(keys.hasNext())
|
||||
{
|
||||
String key = keys.next();
|
||||
Object value = object.get(key);
|
||||
if(value instanceof JSONObject child)
|
||||
{
|
||||
if("table".equals(key))
|
||||
{
|
||||
parent.withChild(mysqlTable(child));
|
||||
}
|
||||
else if(!"cost_info".equals(key))
|
||||
{
|
||||
PlanNode node = new PlanNode(key, null);
|
||||
mysqlChildren(child, node);
|
||||
parent.withChild(node);
|
||||
}
|
||||
}
|
||||
else if(value instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
if(array.get(i) instanceof JSONObject element)
|
||||
{
|
||||
if(element.has("table"))
|
||||
{
|
||||
parent.withChild(mysqlTable(element.getJSONObject("table")));
|
||||
}
|
||||
else
|
||||
{
|
||||
PlanNode node = new PlanNode(key, null);
|
||||
mysqlChildren(element, node);
|
||||
parent.withChild(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static PlanNode mysqlTable(JSONObject table)
|
||||
{
|
||||
PlanNode node = new PlanNode(table.optString("access_type", "table"), table.optString("table_name", null));
|
||||
node.setPlanRows(parseDouble(table.opt("rows_examined_per_scan")));
|
||||
JSONObject costInfo = table.optJSONObject("cost_info");
|
||||
if(costInfo != null)
|
||||
{
|
||||
node.setTotalCost(parseDouble(costInfo.opt("prefix_cost")));
|
||||
}
|
||||
for(String key : List.of("key", "possible_keys", "attached_condition", "filtered"))
|
||||
{
|
||||
if(table.has(key))
|
||||
{
|
||||
node.getExtra().put(key, table.get(key).toString());
|
||||
}
|
||||
}
|
||||
return (node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQLite EXPLAIN QUERY PLAN rows: id, parent, notused, detail.
|
||||
*******************************************************************************/
|
||||
static PlanNode parseSqlite(List<List<Object>> rows)
|
||||
{
|
||||
PlanNode root = new PlanNode("QUERY PLAN", null);
|
||||
Map<Object, PlanNode> byId = new HashMap<>();
|
||||
byId.put("0", root);
|
||||
for(List<Object> row : rows)
|
||||
{
|
||||
String id = String.valueOf(row.get(0));
|
||||
String parent = String.valueOf(row.get(1));
|
||||
String detail = String.valueOf(row.get(row.size() - 1));
|
||||
PlanNode node = new PlanNode(detail, null);
|
||||
byId.put(id, node);
|
||||
byId.getOrDefault(parent, root).withChild(node);
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Text plans: one node per line, nested by leading indentation (MySQL
|
||||
** EXPLAIN ANALYZE uses "-> " lines indented by 4).
|
||||
*******************************************************************************/
|
||||
static PlanNode parseText(List<List<Object>> rows)
|
||||
{
|
||||
PlanNode root = new PlanNode("Plan", null);
|
||||
java.util.Deque<Map.Entry<Integer, PlanNode>> stack = new java.util.ArrayDeque<>();
|
||||
stack.push(Map.entry(-1, root));
|
||||
|
||||
for(String line : raw(rows).split("\n"))
|
||||
{
|
||||
if(line.isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int indent = line.length() - line.stripLeading().length();
|
||||
String text = line.strip();
|
||||
if(text.startsWith("-> "))
|
||||
{
|
||||
text = text.substring(3);
|
||||
}
|
||||
|
||||
PlanNode node = new PlanNode(text, null);
|
||||
while(stack.size() > 1 && stack.peek().getKey() >= indent)
|
||||
{
|
||||
stack.pop();
|
||||
}
|
||||
stack.peek().getValue().withChild(node);
|
||||
stack.push(Map.entry(indent, node));
|
||||
}
|
||||
return (root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Double optDouble(JSONObject object, String key)
|
||||
{
|
||||
return (object.has(key) ? parseDouble(object.get(key)) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Double parseDouble(Object value)
|
||||
{
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (n.doubleValue());
|
||||
}
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Double.parseDouble(value.toString()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sqlWorkbench app: the workbench widget (rendered by the Next UI as the
|
||||
** notebook UI), plus the notebook and execution-audit tables. Gated by the
|
||||
** access permission, so the app - and so the feature - is only advertised
|
||||
** to users who have it. No processes (they would only add left-nav noise).
|
||||
** Hosts can re-parent it (e.g. under an Admin app); its name stays the same.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String APP_NAME = "sqlWorkbench";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAppMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.applyPrefix(APP_NAME))
|
||||
.withLabel("SQL Workbench")
|
||||
.withIcon(new QIcon("terminal"))
|
||||
.withPermissionRules(config.getAccessPermissionRules())
|
||||
.withWidgets(List.of(config.applyPrefix(SqlWorkbenchWidgetMetaDataProducer.NAME)));
|
||||
|
||||
MetaDataProducerMultiOutput produced = QBitProductionContext.peekMetaDataProducerMultiOutput();
|
||||
if(produced != null)
|
||||
{
|
||||
for(String tableName : new String[] { SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME })
|
||||
{
|
||||
QTableMetaData table = produced.get(QTableMetaData.class, config.applyPrefix(tableName));
|
||||
if(table != null)
|
||||
{
|
||||
app.withChild(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** last, so the tables and widget exist in the output
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (900);
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchCell table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCellTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildCellTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (110);
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchExecution (audit) table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchExecutionTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildExecutionTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (140);
|
||||
}
|
||||
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the possible value source over the notebook table, so cell,
|
||||
** version and snapshot rows show the notebook's title.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookPossibleValueSourceProducer extends QBitComponentMetaDataProducer<QPossibleValueSource, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QPossibleValueSource produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildNotebookPossibleValueSource(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the tables
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (200);
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchNotebook table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildNotebookTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (100);
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchNotebookVersion table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookVersionTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildNotebookVersionTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (120);
|
||||
}
|
||||
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produces the sqlWorkbenchResultSnapshot table.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchResultSnapshotTableMetaDataProducer extends QBitComponentMetaDataProducer<QTableMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return (SqlWorkbenchTableMetaDataHelper.buildResultSnapshotTable(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tables first, then the PVS, widget and app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (130);
|
||||
}
|
||||
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds the QTableMetaData of the five SQL Workbench tables. Shared by the
|
||||
** component producers and by tests.
|
||||
**
|
||||
** All tables are protected by the ADMIN permission
|
||||
** (sqlWorkbenchAdmin.hasAccess), not the access permission every workbench
|
||||
** user holds: the generic QQQ table UI / API would otherwise let any user
|
||||
** read other users' personal notebooks, pinned result data and the full
|
||||
** execution audit. The workbench endpoints do their own owner / visibility
|
||||
** checks and then read and write these tables as the system user.
|
||||
**
|
||||
** Cells, versions, snapshots and the audit are read-only even for admins
|
||||
** (insert / edit / delete capabilities disabled); only the qbit writes
|
||||
** them. Notebooks stay editable for admins (e.g. to clean up).
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchTableMetaDataHelper
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchNotebook
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotebookTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
return (text(base(config, SqlWorkbenchNotebook.TABLE_NAME, "SQL Notebook", "menu_book", SqlWorkbenchNotebook.class), "description", "parametersJson")
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("title"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchCell
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildCellTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchCell.TABLE_NAME, "SQL Notebook Cell", "notes", SqlWorkbenchCell.class), "content");
|
||||
table.setIsHidden(true);
|
||||
table.withUniqueKey(new UniqueKey("notebookId", "cellKey"));
|
||||
readOnly(table);
|
||||
return (linkNotebook(config, table));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchNotebookVersion
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotebookVersionTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchNotebookVersion.TABLE_NAME, "SQL Notebook Version", "history", SqlWorkbenchNotebookVersion.class), "contentJson");
|
||||
table.setIsHidden(true);
|
||||
table.withUniqueKey(new UniqueKey("notebookId", "versionNumber"));
|
||||
readOnly(table);
|
||||
return (linkNotebook(config, table));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchResultSnapshot
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildResultSnapshotTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchResultSnapshot.TABLE_NAME, "SQL Result Snapshot", "push_pin", SqlWorkbenchResultSnapshot.class), "sqlText", "resultJson");
|
||||
table.setIsHidden(true);
|
||||
readOnly(table);
|
||||
return (linkNotebook(config, table));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sqlWorkbenchExecution (the audit)
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildExecutionTable(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, SqlWorkbenchExecution.TABLE_NAME, "SQL Execution Audit", "policy", SqlWorkbenchExecution.class), "sqlText", "parametersJson", "errorMessage");
|
||||
table.withUniqueKey(new UniqueKey("executionUuid"));
|
||||
readOnly(table);
|
||||
table.withRecordLabelFormat("%s %s").withRecordLabelFields("mode", "executionUuid");
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value source over the notebook table, for notebookId fields.
|
||||
*******************************************************************************/
|
||||
public static QPossibleValueSource buildNotebookPossibleValueSource(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
return (QPossibleValueSource.newForTable(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All five tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> buildAllTables(SqlWorkbenchQBitConfig config) throws QException
|
||||
{
|
||||
return (List.of(buildNotebookTable(config), buildCellTable(config), buildNotebookVersionTable(config), buildResultSnapshotTable(config), buildExecutionTable(config)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(SqlWorkbenchQBitConfig config, String name, String label, String icon, Class<? extends QRecordEntity> entity) throws QException
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData();
|
||||
table.withName(config.applyPrefix(name))
|
||||
.withLabel(label)
|
||||
.withIcon(new QIcon(icon))
|
||||
.withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id")
|
||||
.withPermissionRules(config.getAdminPermissionRules())
|
||||
.withFieldsFromEntity(entity);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the qbit (as the system user) writes this table.
|
||||
*******************************************************************************/
|
||||
private static void readOnly(QTableMetaData table)
|
||||
{
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Point notebookId at the (possibly prefixed) notebook PVS.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData linkNotebook(SqlWorkbenchQBitConfig config, QTableMetaData table)
|
||||
{
|
||||
QFieldMetaData field = table.getField("notebookId");
|
||||
field.setPossibleValueSourceName(config.applyPrefix(SqlWorkbenchNotebook.TABLE_NAME));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make String fields TEXT (unbounded).
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData text(QTableMetaData table, String... names)
|
||||
{
|
||||
for(String name : names)
|
||||
{
|
||||
table.getField(name).setType(QFieldType.TEXT);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench widget, of type SQL_WORKBENCH: the Next UI renders the whole
|
||||
** notebook UI for it from the /qqq/v1/sql-workbench endpoints (as it renders
|
||||
** ESB_OVERVIEW from the ESB endpoints). Same permission as the app.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchWidgetMetaDataProducer extends QBitComponentMetaDataProducer<QWidgetMetaData, SqlWorkbenchQBitConfig>
|
||||
{
|
||||
public static final String NAME = "sqlWorkbenchWidget";
|
||||
public static final String TYPE = "SQL_WORKBENCH";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = getQBitConfig();
|
||||
return (new QWidgetMetaData()
|
||||
.withName(config.applyPrefix(NAME))
|
||||
.withLabel("SQL Workbench")
|
||||
.withType(TYPE)
|
||||
.withGridColumns(12)
|
||||
.withIsCard(false)
|
||||
.withCodeReference(new QCodeReference(SqlWorkbenchWidgetRenderer.class))
|
||||
.withPermissionRules(config.getAccessPermissionRules()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** after the tables and PVS, before the app
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getSortOrder()
|
||||
{
|
||||
return (300);
|
||||
}
|
||||
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renderer for the SQL Workbench widget. The frontend draws the widget from
|
||||
** the (permission-checked) /qqq/v1/sql-workbench endpoints, so the widget's
|
||||
** own data is just its type.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new SqlWorkbenchWidgetData()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget data: its type, and nothing else.
|
||||
*******************************************************************************/
|
||||
public static class SqlWorkbenchWidgetData extends QWidgetData
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (SqlWorkbenchWidgetMetaDataProducer.TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One cell of a notebook: SQL or markdown, in sortOrder.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchCell extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchCell";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 64)
|
||||
private String cellKey;
|
||||
|
||||
@QField()
|
||||
private Integer sortOrder;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String cellType;
|
||||
|
||||
@QField(maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String title;
|
||||
|
||||
@QField()
|
||||
private String content;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String backendName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellKey
|
||||
*******************************************************************************/
|
||||
public String getCellKey()
|
||||
{
|
||||
return (this.cellKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellKey
|
||||
*******************************************************************************/
|
||||
public void setCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellKey
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sortOrder
|
||||
*******************************************************************************/
|
||||
public Integer getSortOrder()
|
||||
{
|
||||
return (this.sortOrder);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sortOrder
|
||||
*******************************************************************************/
|
||||
public void setSortOrder(Integer sortOrder)
|
||||
{
|
||||
this.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sortOrder
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withSortOrder(Integer sortOrder)
|
||||
{
|
||||
this.sortOrder = sortOrder;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellType
|
||||
*******************************************************************************/
|
||||
public String getCellType()
|
||||
{
|
||||
return (this.cellType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellType
|
||||
*******************************************************************************/
|
||||
public void setCellType(String cellType)
|
||||
{
|
||||
this.cellType = cellType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellType
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withCellType(String cellType)
|
||||
{
|
||||
this.cellType = cellType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for content
|
||||
*******************************************************************************/
|
||||
public String getContent()
|
||||
{
|
||||
return (this.content);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for content
|
||||
*******************************************************************************/
|
||||
public void setContent(String content)
|
||||
{
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for content
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withContent(String content)
|
||||
{
|
||||
this.content = content;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (this.backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchCell withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit record of one SQL Workbench execution: who, when, where, what SQL,
|
||||
** in which mode, and how it ended. Append-only: written before the SQL runs
|
||||
** and completed after.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchExecution extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchExecution";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isEditable = false, maxLength = 36)
|
||||
private String executionUuid;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String userName;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String backendName;
|
||||
|
||||
@QField(isEditable = false, maxLength = 24)
|
||||
private String mode;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String status;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String sqlText;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String sqlHash;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String parametersJson;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer statementCount;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String statementKinds;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String cellKey;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant startTime;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant endTime;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer durationMs;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer rowsReturned;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer rowsAffected;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Boolean truncated;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String errorMessage;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String clientIp;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for executionUuid
|
||||
*******************************************************************************/
|
||||
public String getExecutionUuid()
|
||||
{
|
||||
return (this.executionUuid);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public void setExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userName
|
||||
*******************************************************************************/
|
||||
public String getUserName()
|
||||
{
|
||||
return (this.userName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userName
|
||||
*******************************************************************************/
|
||||
public void setUserName(String userName)
|
||||
{
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withUserName(String userName)
|
||||
{
|
||||
this.userName = userName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (this.backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for mode
|
||||
*******************************************************************************/
|
||||
public String getMode()
|
||||
{
|
||||
return (this.mode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for mode
|
||||
*******************************************************************************/
|
||||
public void setMode(String mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for mode
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withMode(String mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (this.status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sqlText
|
||||
*******************************************************************************/
|
||||
public String getSqlText()
|
||||
{
|
||||
return (this.sqlText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sqlText
|
||||
*******************************************************************************/
|
||||
public void setSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sqlText
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sqlHash
|
||||
*******************************************************************************/
|
||||
public String getSqlHash()
|
||||
{
|
||||
return (this.sqlHash);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sqlHash
|
||||
*******************************************************************************/
|
||||
public void setSqlHash(String sqlHash)
|
||||
{
|
||||
this.sqlHash = sqlHash;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sqlHash
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withSqlHash(String sqlHash)
|
||||
{
|
||||
this.sqlHash = sqlHash;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parametersJson
|
||||
*******************************************************************************/
|
||||
public String getParametersJson()
|
||||
{
|
||||
return (this.parametersJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public void setParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for statementCount
|
||||
*******************************************************************************/
|
||||
public Integer getStatementCount()
|
||||
{
|
||||
return (this.statementCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for statementCount
|
||||
*******************************************************************************/
|
||||
public void setStatementCount(Integer statementCount)
|
||||
{
|
||||
this.statementCount = statementCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for statementCount
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStatementCount(Integer statementCount)
|
||||
{
|
||||
this.statementCount = statementCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for statementKinds
|
||||
*******************************************************************************/
|
||||
public String getStatementKinds()
|
||||
{
|
||||
return (this.statementKinds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for statementKinds
|
||||
*******************************************************************************/
|
||||
public void setStatementKinds(String statementKinds)
|
||||
{
|
||||
this.statementKinds = statementKinds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for statementKinds
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStatementKinds(String statementKinds)
|
||||
{
|
||||
this.statementKinds = statementKinds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellKey
|
||||
*******************************************************************************/
|
||||
public String getCellKey()
|
||||
{
|
||||
return (this.cellKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellKey
|
||||
*******************************************************************************/
|
||||
public void setCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellKey
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startTime
|
||||
*******************************************************************************/
|
||||
public Instant getStartTime()
|
||||
{
|
||||
return (this.startTime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for startTime
|
||||
*******************************************************************************/
|
||||
public void setStartTime(Instant startTime)
|
||||
{
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for startTime
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withStartTime(Instant startTime)
|
||||
{
|
||||
this.startTime = startTime;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for endTime
|
||||
*******************************************************************************/
|
||||
public Instant getEndTime()
|
||||
{
|
||||
return (this.endTime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for endTime
|
||||
*******************************************************************************/
|
||||
public void setEndTime(Instant endTime)
|
||||
{
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for endTime
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withEndTime(Instant endTime)
|
||||
{
|
||||
this.endTime = endTime;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for durationMs
|
||||
*******************************************************************************/
|
||||
public Integer getDurationMs()
|
||||
{
|
||||
return (this.durationMs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for durationMs
|
||||
*******************************************************************************/
|
||||
public void setDurationMs(Integer durationMs)
|
||||
{
|
||||
this.durationMs = durationMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for durationMs
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withDurationMs(Integer durationMs)
|
||||
{
|
||||
this.durationMs = durationMs;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowsReturned
|
||||
*******************************************************************************/
|
||||
public Integer getRowsReturned()
|
||||
{
|
||||
return (this.rowsReturned);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rowsReturned
|
||||
*******************************************************************************/
|
||||
public void setRowsReturned(Integer rowsReturned)
|
||||
{
|
||||
this.rowsReturned = rowsReturned;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowsReturned
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withRowsReturned(Integer rowsReturned)
|
||||
{
|
||||
this.rowsReturned = rowsReturned;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowsAffected
|
||||
*******************************************************************************/
|
||||
public Integer getRowsAffected()
|
||||
{
|
||||
return (this.rowsAffected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rowsAffected
|
||||
*******************************************************************************/
|
||||
public void setRowsAffected(Integer rowsAffected)
|
||||
{
|
||||
this.rowsAffected = rowsAffected;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowsAffected
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withRowsAffected(Integer rowsAffected)
|
||||
{
|
||||
this.rowsAffected = rowsAffected;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated
|
||||
*******************************************************************************/
|
||||
public Boolean getTruncated()
|
||||
{
|
||||
return (this.truncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for truncated
|
||||
*******************************************************************************/
|
||||
public void setTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for truncated
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errorMessage
|
||||
*******************************************************************************/
|
||||
public String getErrorMessage()
|
||||
{
|
||||
return (this.errorMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for errorMessage
|
||||
*******************************************************************************/
|
||||
public void setErrorMessage(String errorMessage)
|
||||
{
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for errorMessage
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withErrorMessage(String errorMessage)
|
||||
{
|
||||
this.errorMessage = errorMessage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientIp
|
||||
*******************************************************************************/
|
||||
public String getClientIp()
|
||||
{
|
||||
return (this.clientIp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clientIp
|
||||
*******************************************************************************/
|
||||
public void setClientIp(String clientIp)
|
||||
{
|
||||
this.clientIp = clientIp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientIp
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withClientIp(String clientIp)
|
||||
{
|
||||
this.clientIp = clientIp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchExecution withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A saved notebook: ordered SQL and markdown cells, owned by a user, personal or
|
||||
** shared through the library.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebook extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchNotebook";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String title;
|
||||
|
||||
@QField()
|
||||
private String description;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, maxLength = 255)
|
||||
private String ownerUserId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String ownerName;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String visibility;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String defaultBackendName;
|
||||
|
||||
@QField()
|
||||
private String parametersJson;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer currentVersion;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for description
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ownerUserId
|
||||
*******************************************************************************/
|
||||
public String getOwnerUserId()
|
||||
{
|
||||
return (this.ownerUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ownerUserId
|
||||
*******************************************************************************/
|
||||
public void setOwnerUserId(String ownerUserId)
|
||||
{
|
||||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ownerUserId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withOwnerUserId(String ownerUserId)
|
||||
{
|
||||
this.ownerUserId = ownerUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for ownerName
|
||||
*******************************************************************************/
|
||||
public String getOwnerName()
|
||||
{
|
||||
return (this.ownerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for ownerName
|
||||
*******************************************************************************/
|
||||
public void setOwnerName(String ownerName)
|
||||
{
|
||||
this.ownerName = ownerName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for ownerName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withOwnerName(String ownerName)
|
||||
{
|
||||
this.ownerName = ownerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for visibility
|
||||
*******************************************************************************/
|
||||
public String getVisibility()
|
||||
{
|
||||
return (this.visibility);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for visibility
|
||||
*******************************************************************************/
|
||||
public void setVisibility(String visibility)
|
||||
{
|
||||
this.visibility = visibility;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for visibility
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withVisibility(String visibility)
|
||||
{
|
||||
this.visibility = visibility;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultBackendName
|
||||
*******************************************************************************/
|
||||
public String getDefaultBackendName()
|
||||
{
|
||||
return (this.defaultBackendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for defaultBackendName
|
||||
*******************************************************************************/
|
||||
public void setDefaultBackendName(String defaultBackendName)
|
||||
{
|
||||
this.defaultBackendName = defaultBackendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultBackendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withDefaultBackendName(String defaultBackendName)
|
||||
{
|
||||
this.defaultBackendName = defaultBackendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parametersJson
|
||||
*******************************************************************************/
|
||||
public String getParametersJson()
|
||||
{
|
||||
return (this.parametersJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public void setParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parametersJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withParametersJson(String parametersJson)
|
||||
{
|
||||
this.parametersJson = parametersJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for currentVersion
|
||||
*******************************************************************************/
|
||||
public Integer getCurrentVersion()
|
||||
{
|
||||
return (this.currentVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for currentVersion
|
||||
*******************************************************************************/
|
||||
public void setCurrentVersion(Integer currentVersion)
|
||||
{
|
||||
this.currentVersion = currentVersion;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for currentVersion
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withCurrentVersion(Integer currentVersion)
|
||||
{
|
||||
this.currentVersion = currentVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebook withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An immutable snapshot of a notebook and its cells, written on every save.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchNotebookVersion extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchNotebookVersion";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isRequired = true, isEditable = false)
|
||||
private Integer versionNumber;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String contentJson;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String savedByUserId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String savedByName;
|
||||
|
||||
@QField(maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String comment;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for versionNumber
|
||||
*******************************************************************************/
|
||||
public Integer getVersionNumber()
|
||||
{
|
||||
return (this.versionNumber);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for versionNumber
|
||||
*******************************************************************************/
|
||||
public void setVersionNumber(Integer versionNumber)
|
||||
{
|
||||
this.versionNumber = versionNumber;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for versionNumber
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withVersionNumber(Integer versionNumber)
|
||||
{
|
||||
this.versionNumber = versionNumber;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for contentJson
|
||||
*******************************************************************************/
|
||||
public String getContentJson()
|
||||
{
|
||||
return (this.contentJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for contentJson
|
||||
*******************************************************************************/
|
||||
public void setContentJson(String contentJson)
|
||||
{
|
||||
this.contentJson = contentJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for contentJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withContentJson(String contentJson)
|
||||
{
|
||||
this.contentJson = contentJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for savedByUserId
|
||||
*******************************************************************************/
|
||||
public String getSavedByUserId()
|
||||
{
|
||||
return (this.savedByUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for savedByUserId
|
||||
*******************************************************************************/
|
||||
public void setSavedByUserId(String savedByUserId)
|
||||
{
|
||||
this.savedByUserId = savedByUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for savedByUserId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withSavedByUserId(String savedByUserId)
|
||||
{
|
||||
this.savedByUserId = savedByUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for savedByName
|
||||
*******************************************************************************/
|
||||
public String getSavedByName()
|
||||
{
|
||||
return (this.savedByName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for savedByName
|
||||
*******************************************************************************/
|
||||
public void setSavedByName(String savedByName)
|
||||
{
|
||||
this.savedByName = savedByName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for savedByName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withSavedByName(String savedByName)
|
||||
{
|
||||
this.savedByName = savedByName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for comment
|
||||
*******************************************************************************/
|
||||
public String getComment()
|
||||
{
|
||||
return (this.comment);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for comment
|
||||
*******************************************************************************/
|
||||
public void setComment(String comment)
|
||||
{
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for comment
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withComment(String comment)
|
||||
{
|
||||
this.comment = comment;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchNotebookVersion withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A result pinned to a notebook cell: the columns and (capped) rows of one
|
||||
** execution.
|
||||
*******************************************************************************/
|
||||
public class SqlWorkbenchResultSnapshot extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "sqlWorkbenchResultSnapshot";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, possibleValueSourceName = SqlWorkbenchNotebook.TABLE_NAME)
|
||||
private Integer notebookId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String cellKey;
|
||||
|
||||
@QField(isEditable = false, maxLength = 36)
|
||||
private String executionUuid;
|
||||
|
||||
@QField(maxLength = 255, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String label;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String backendName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String sqlText;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String resultJson;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer rowCount;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Boolean truncated;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String createdByUserId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notebookId
|
||||
*******************************************************************************/
|
||||
public Integer getNotebookId()
|
||||
{
|
||||
return (this.notebookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notebookId
|
||||
*******************************************************************************/
|
||||
public void setNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notebookId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withNotebookId(Integer notebookId)
|
||||
{
|
||||
this.notebookId = notebookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cellKey
|
||||
*******************************************************************************/
|
||||
public String getCellKey()
|
||||
{
|
||||
return (this.cellKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cellKey
|
||||
*******************************************************************************/
|
||||
public void setCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cellKey
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withCellKey(String cellKey)
|
||||
{
|
||||
this.cellKey = cellKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for executionUuid
|
||||
*******************************************************************************/
|
||||
public String getExecutionUuid()
|
||||
{
|
||||
return (this.executionUuid);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public void setExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for executionUuid
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withExecutionUuid(String executionUuid)
|
||||
{
|
||||
this.executionUuid = executionUuid;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (this.backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sqlText
|
||||
*******************************************************************************/
|
||||
public String getSqlText()
|
||||
{
|
||||
return (this.sqlText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sqlText
|
||||
*******************************************************************************/
|
||||
public void setSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sqlText
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withSqlText(String sqlText)
|
||||
{
|
||||
this.sqlText = sqlText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resultJson
|
||||
*******************************************************************************/
|
||||
public String getResultJson()
|
||||
{
|
||||
return (this.resultJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for resultJson
|
||||
*******************************************************************************/
|
||||
public void setResultJson(String resultJson)
|
||||
{
|
||||
this.resultJson = resultJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resultJson
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withResultJson(String resultJson)
|
||||
{
|
||||
this.resultJson = resultJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rowCount
|
||||
*******************************************************************************/
|
||||
public Integer getRowCount()
|
||||
{
|
||||
return (this.rowCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rowCount
|
||||
*******************************************************************************/
|
||||
public void setRowCount(Integer rowCount)
|
||||
{
|
||||
this.rowCount = rowCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for rowCount
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withRowCount(Integer rowCount)
|
||||
{
|
||||
this.rowCount = rowCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for truncated
|
||||
*******************************************************************************/
|
||||
public Boolean getTruncated()
|
||||
{
|
||||
return (this.truncated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for truncated
|
||||
*******************************************************************************/
|
||||
public void setTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for truncated
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withTruncated(Boolean truncated)
|
||||
{
|
||||
this.truncated = truncated;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public String getCreatedByUserId()
|
||||
{
|
||||
return (this.createdByUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public void setCreatedByUserId(String createdByUserId)
|
||||
{
|
||||
this.createdByUserId = createdByUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createdByUserId
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withCreatedByUserId(String createdByUserId)
|
||||
{
|
||||
this.createdByUserId = createdByUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public SqlWorkbenchResultSnapshot withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.notebook;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.SystemUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.CollectedResult;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebooks, their cells, versions and pinned result snapshots.
|
||||
**
|
||||
** Visibility: a caller reads their own notebooks and every SHARED one; only
|
||||
** the owner edits, deletes, restores and pins; publishing needs the share
|
||||
** permission and ownership; anyone with access can duplicate what they can
|
||||
** read.
|
||||
**
|
||||
** These checks are the boundary: the tables themselves are guarded by the
|
||||
** admin permission (sqlWorkbenchAdmin.hasAccess), which ordinary users do
|
||||
** not hold, so the generic QQQ table UI / API cannot reach other users'
|
||||
** notebooks. Every read and write here runs as the system user (SystemUser)
|
||||
** and every public method checks the Caller first (readable / owned /
|
||||
** canShare) before it reads content or writes anything.
|
||||
**
|
||||
** Saves replace a notebook's cells and append a version (full JSON), in one
|
||||
** transaction; versions beyond notebookVersionRetention are pruned.
|
||||
*******************************************************************************/
|
||||
public class NotebookService
|
||||
{
|
||||
public static final String VISIBILITY_PERSONAL = "PERSONAL";
|
||||
public static final String VISIBILITY_SHARED = "SHARED";
|
||||
public static final String CELL_SQL = "SQL";
|
||||
public static final String CELL_MARKDOWN = "MARKDOWN";
|
||||
public static final int MAX_CELLS = 200;
|
||||
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final SqlWorkbenchQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public NotebookService(SqlWorkbenchRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.config = runtime.getConfig();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String table(String name)
|
||||
{
|
||||
return (config.applyPrefix(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook summaries: scope "mine" (default) or "shared".
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> list(String scope, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
QQueryFilter filter = "shared".equalsIgnoreCase(scope)
|
||||
? new QQueryFilter(new QFilterCriteria("visibility", QCriteriaOperator.EQUALS, VISIBILITY_SHARED))
|
||||
: new QQueryFilter(new QFilterCriteria("ownerUserId", QCriteriaOperator.EQUALS, caller.userId()));
|
||||
filter.withOrderBy(new QFilterOrderBy("modifyDate", false)).withLimit(500);
|
||||
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), filter))
|
||||
{
|
||||
result.add(summary(new SqlWorkbenchNotebook(record), caller));
|
||||
}
|
||||
return (result);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One notebook with its cells.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> get(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = readable(id, caller);
|
||||
Map<String, Object> view = summary(notebook, caller);
|
||||
view.put("parameters", parseParameters(notebook.getParametersJson()));
|
||||
view.put("cells", cells(notebook.getId()).stream().map(NotebookService::cellView).toList());
|
||||
return (view);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a personal notebook (version 1).
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> create(Map<String, Object> body, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
SqlWorkbenchNotebook notebook = new SqlWorkbenchNotebook()
|
||||
.withOwnerUserId(caller.userId())
|
||||
.withOwnerName(caller.userName())
|
||||
.withVisibility(VISIBILITY_PERSONAL)
|
||||
.withCurrentVersion(0)
|
||||
.withCreateDate(now);
|
||||
applyFields(notebook, body, true);
|
||||
List<SqlWorkbenchCell> cells = parseCells(body.get("cells"));
|
||||
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
notebook.setModifyDate(now);
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withRecordEntity(notebook).withTransaction(transaction));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
notebook.setId(output.getRecords().get(0).getValueInteger("id"));
|
||||
saveContent(notebook, cells, caller, (String) body.get("comment"), transaction);
|
||||
commit(transaction);
|
||||
}
|
||||
return (get(notebook.getId(), caller));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save a notebook: fields, cells (replaced), and a new version.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> update(Integer id, Map<String, Object> body, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = owned(id, caller);
|
||||
applyFields(notebook, body, false);
|
||||
List<SqlWorkbenchCell> cells = body.containsKey("cells") ? parseCells(body.get("cells")) : cells(id);
|
||||
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
saveContent(notebook, cells, caller, (String) body.get("comment"), transaction);
|
||||
commit(transaction);
|
||||
}
|
||||
pruneVersions(id);
|
||||
return (get(id, caller));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a notebook and everything under it.
|
||||
*******************************************************************************/
|
||||
public void delete(Integer id, Caller caller) throws QException
|
||||
{
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
owned(id, caller);
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput(table(SqlWorkbenchNotebook.TABLE_NAME))))
|
||||
{
|
||||
for(String child : List.of(SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME, SqlWorkbenchResultSnapshot.TABLE_NAME))
|
||||
{
|
||||
new DeleteAction().execute(new DeleteInput(table(child)).withQueryFilter(byNotebook(id)).withTransaction(transaction));
|
||||
}
|
||||
new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withPrimaryKeys(List.of(id)).withTransaction(transaction));
|
||||
commit(transaction);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish to (or withdraw from) the shared library.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> publish(Integer id, boolean shared, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
if(!caller.canShare())
|
||||
{
|
||||
throw (new QPermissionDeniedException("Publishing to the shared library needs the " + config.getSharePermissionName() + " permission."));
|
||||
}
|
||||
SqlWorkbenchNotebook notebook = owned(id, caller);
|
||||
new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withRecord(new QRecord()
|
||||
.withValue("id", notebook.getId())
|
||||
.withValue("visibility", shared ? VISIBILITY_SHARED : VISIBILITY_PERSONAL)
|
||||
.withValue("modifyDate", runtime.getClock().instant())));
|
||||
return (get(id, caller));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A personal copy of a readable notebook.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> duplicate(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
SqlWorkbenchNotebook source = readable(id, caller);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("title", truncate("Copy of " + source.getTitle(), 255));
|
||||
body.put("description", source.getDescription());
|
||||
body.put("defaultBackendName", source.getDefaultBackendName());
|
||||
body.put("parameters", parseParameters(source.getParametersJson()));
|
||||
List<Map<String, Object>> cells = new ArrayList<>();
|
||||
for(SqlWorkbenchCell cell : cells(id))
|
||||
{
|
||||
Map<String, Object> view = cellView(cell);
|
||||
view.put("cellKey", UUID.randomUUID().toString());
|
||||
cells.add(view);
|
||||
}
|
||||
body.put("cells", cells);
|
||||
body.put("comment", "Duplicated from notebook " + id);
|
||||
return (create(body, caller));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version list, newest first.
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> versions(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
readable(id, caller);
|
||||
QQueryFilter filter = byNotebook(id).withOrderBy(new QFilterOrderBy("versionNumber", false));
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for(QRecord record : QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter))
|
||||
{
|
||||
SqlWorkbenchNotebookVersion version = new SqlWorkbenchNotebookVersion(record);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("versionNumber", version.getVersionNumber());
|
||||
view.put("savedByName", version.getSavedByName());
|
||||
view.put("comment", version.getComment());
|
||||
view.put("createDate", String.valueOf(version.getCreateDate()));
|
||||
result.add(view);
|
||||
}
|
||||
return (result);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One version's content.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> version(Integer id, Integer versionNumber, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
readable(id, caller);
|
||||
SqlWorkbenchNotebookVersion version = findVersion(id, versionNumber);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("versionNumber", version.getVersionNumber());
|
||||
view.put("savedByName", version.getSavedByName());
|
||||
view.put("comment", version.getComment());
|
||||
view.put("createDate", String.valueOf(version.getCreateDate()));
|
||||
view.put("notebook", parseJsonObject(version.getContentJson()));
|
||||
return (view);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore a version (saved as a new version).
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> restore(Integer id, Integer versionNumber, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
owned(id, caller);
|
||||
Map<String, Object> content = parseJsonObject(findVersion(id, versionNumber).getContentJson());
|
||||
Map<String, Object> body = new LinkedHashMap<>(content);
|
||||
body.put("comment", "Restored version " + versionNumber);
|
||||
return (update(id, body, caller));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pinned snapshots of a notebook.
|
||||
*******************************************************************************/
|
||||
public List<Map<String, Object>> snapshots(Integer id, Caller caller) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
readable(id, caller);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for(QRecord record : QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), byNotebook(id).withOrderBy(new QFilterOrderBy("id", false))))
|
||||
{
|
||||
result.add(snapshotView(new SqlWorkbenchResultSnapshot(record)));
|
||||
}
|
||||
return (result);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin a cached result of the caller to a notebook cell.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> createSnapshot(Integer id, Map<String, Object> body, Caller caller, String sqlText) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
owned(id, caller);
|
||||
String executionUuid = (String) body.get("executionUuid");
|
||||
ResultCache.Entry entry = runtime.getResultCache().get(executionUuid, caller.userId())
|
||||
.orElseThrow(() -> new QNotFoundException("That result is no longer available; run the cell again, then pin it."));
|
||||
|
||||
CollectedResult result = entry.result();
|
||||
int max = config.getSnapshotMaxRows();
|
||||
List<List<Object>> rows = result.rows().size() > max ? result.rows().subList(0, max) : result.rows();
|
||||
Map<String, Object> json = new LinkedHashMap<>();
|
||||
json.put("columns", result.columns().stream().map(ColumnInfo::toMap).toList());
|
||||
json.put("rows", rows);
|
||||
|
||||
SqlWorkbenchResultSnapshot snapshot = new SqlWorkbenchResultSnapshot()
|
||||
.withNotebookId(id)
|
||||
.withCellKey((String) body.get("cellKey"))
|
||||
.withExecutionUuid(executionUuid)
|
||||
.withLabel(truncate((String) body.get("label"), 255))
|
||||
.withBackendName(entry.backendName())
|
||||
.withSqlText(sqlText)
|
||||
.withResultJson(JsonUtils.toJson(json))
|
||||
.withRowCount(rows.size())
|
||||
.withTruncated(result.truncated() || result.rows().size() > max)
|
||||
.withCreatedByUserId(caller.userId())
|
||||
.withCreateDate(runtime.getClock().instant());
|
||||
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)).withRecordEntity(snapshot));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
snapshot.setId(output.getRecords().get(0).getValueInteger("id"));
|
||||
return (snapshotView(snapshot));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unpin.
|
||||
*******************************************************************************/
|
||||
public void deleteSnapshot(Integer id, Integer snapshotId, Caller caller) throws QException
|
||||
{
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
owned(id, caller);
|
||||
QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("id", QCriteriaOperator.EQUALS, snapshotId));
|
||||
if(QueryAction.execute(table(SqlWorkbenchResultSnapshot.TABLE_NAME), filter).isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Snapshot not found."));
|
||||
}
|
||||
new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchResultSnapshot.TABLE_NAME)).withPrimaryKeys(List.of(snapshotId)));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook the caller may read, or 404 (never 403: personal notebooks of
|
||||
** others do not exist for the caller).
|
||||
*******************************************************************************/
|
||||
SqlWorkbenchNotebook readable(Integer id, Caller caller) throws QException
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = find(id);
|
||||
if(notebook == null || !(Objects.equals(notebook.getOwnerUserId(), caller.userId()) || VISIBILITY_SHARED.equals(notebook.getVisibility())))
|
||||
{
|
||||
throw (new QNotFoundException("Notebook not found."));
|
||||
}
|
||||
return (notebook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notebook the caller owns: 404 if unreadable, 403 if shared but not
|
||||
** theirs.
|
||||
*******************************************************************************/
|
||||
SqlWorkbenchNotebook owned(Integer id, Caller caller) throws QException
|
||||
{
|
||||
SqlWorkbenchNotebook notebook = readable(id, caller);
|
||||
if(!Objects.equals(notebook.getOwnerUserId(), caller.userId()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Only the notebook's owner can change it; duplicate it to make your own copy."));
|
||||
}
|
||||
return (notebook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchNotebook find(Integer id) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
throw (new QBadRequestException("A notebook id is required."));
|
||||
}
|
||||
List<QRecord> records = QueryAction.execute(table(SqlWorkbenchNotebook.TABLE_NAME), new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id)));
|
||||
return (records.isEmpty() ? null : new SqlWorkbenchNotebook(records.get(0)));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<SqlWorkbenchCell> cells(Integer notebookId) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
QQueryFilter filter = byNotebook(notebookId).withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id"));
|
||||
return (QueryAction.execute(table(SqlWorkbenchCell.TABLE_NAME), SqlWorkbenchCell.class, filter));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlWorkbenchNotebookVersion findVersion(Integer id, Integer versionNumber) throws QException
|
||||
{
|
||||
return (SystemUser.call(() ->
|
||||
{
|
||||
QQueryFilter filter = byNotebook(id).withCriteria(new QFilterCriteria("versionNumber", QCriteriaOperator.EQUALS, versionNumber));
|
||||
List<QRecord> records = QueryAction.execute(table(SqlWorkbenchNotebookVersion.TABLE_NAME), filter);
|
||||
if(records.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Version not found."));
|
||||
}
|
||||
return (new SqlWorkbenchNotebookVersion(records.get(0)));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter byNotebook(Integer id)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria("notebookId", QCriteriaOperator.EQUALS, id)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write the notebook row (version + 1), replace its cells, append a
|
||||
** version - all in the given transaction.
|
||||
*******************************************************************************/
|
||||
private void saveContent(SqlWorkbenchNotebook notebook, List<SqlWorkbenchCell> cells, Caller caller, String comment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
int version = (notebook.getCurrentVersion() == null ? 0 : notebook.getCurrentVersion()) + 1;
|
||||
notebook.setCurrentVersion(version);
|
||||
notebook.setModifyDate(now);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput(table(SqlWorkbenchNotebook.TABLE_NAME)).withTransaction(transaction).withRecord(new QRecord()
|
||||
.withValue("id", notebook.getId())
|
||||
.withValue("title", notebook.getTitle())
|
||||
.withValue("description", notebook.getDescription())
|
||||
.withValue("defaultBackendName", notebook.getDefaultBackendName())
|
||||
.withValue("parametersJson", notebook.getParametersJson())
|
||||
.withValue("currentVersion", version)
|
||||
.withValue("modifyDate", now)));
|
||||
|
||||
new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchCell.TABLE_NAME)).withQueryFilter(byNotebook(notebook.getId())).withTransaction(transaction));
|
||||
for(int i = 0; i < cells.size(); i++)
|
||||
{
|
||||
cells.get(i).withId(null).withNotebookId(notebook.getId()).withSortOrder(i).withCreateDate(now).withModifyDate(now);
|
||||
}
|
||||
if(!cells.isEmpty())
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchCell.TABLE_NAME)).withRecordEntities(cells).withTransaction(transaction));
|
||||
for(QRecord record : output.getRecords())
|
||||
{
|
||||
throwIfErrors(record);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> content = new LinkedHashMap<>();
|
||||
content.put("title", notebook.getTitle());
|
||||
content.put("description", notebook.getDescription());
|
||||
content.put("defaultBackendName", notebook.getDefaultBackendName());
|
||||
content.put("parameters", parseParameters(notebook.getParametersJson()));
|
||||
content.put("cells", cells.stream().map(NotebookService::cellView).toList());
|
||||
|
||||
SqlWorkbenchNotebookVersion versionRow = new SqlWorkbenchNotebookVersion()
|
||||
.withNotebookId(notebook.getId())
|
||||
.withVersionNumber(version)
|
||||
.withContentJson(JsonUtils.toJson(content))
|
||||
.withSavedByUserId(caller.userId())
|
||||
.withSavedByName(caller.userName())
|
||||
.withComment(truncate(comment, 255))
|
||||
.withCreateDate(now);
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME)).withRecordEntity(versionRow).withTransaction(transaction));
|
||||
throwIfErrors(output.getRecords().get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete versions beyond the retention, oldest first.
|
||||
*******************************************************************************/
|
||||
private void pruneVersions(Integer id) throws QException
|
||||
{
|
||||
SystemUser.run(() ->
|
||||
{
|
||||
QueryInput input = new QueryInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME))
|
||||
.withFilter(byNotebook(id).withOrderBy(new QFilterOrderBy("versionNumber", false)));
|
||||
List<QRecord> records = new QueryAction().execute(input).getRecords();
|
||||
if(records.size() <= config.getNotebookVersionRetention())
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<Serializable> stale = new ArrayList<>();
|
||||
for(QRecord record : records.subList(config.getNotebookVersionRetention(), records.size()))
|
||||
{
|
||||
stale.add(record.getValueInteger("id"));
|
||||
}
|
||||
new DeleteAction().execute(new DeleteInput(table(SqlWorkbenchNotebookVersion.TABLE_NAME)).withPrimaryKeys(stale));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply title / description / defaultBackendName / parameters from a body.
|
||||
*******************************************************************************/
|
||||
private void applyFields(SqlWorkbenchNotebook notebook, Map<String, Object> body, boolean creating) throws QException
|
||||
{
|
||||
if(creating || body.containsKey("title"))
|
||||
{
|
||||
String title = body.get("title") == null ? null : body.get("title").toString().trim();
|
||||
if(!StringUtils.hasContent(title))
|
||||
{
|
||||
throw (new QBadRequestException("A notebook needs a title."));
|
||||
}
|
||||
notebook.setTitle(truncate(title, 255));
|
||||
}
|
||||
if(body.containsKey("description"))
|
||||
{
|
||||
notebook.setDescription(body.get("description") == null ? null : body.get("description").toString());
|
||||
}
|
||||
if(body.containsKey("defaultBackendName"))
|
||||
{
|
||||
notebook.setDefaultBackendName(body.get("defaultBackendName") == null ? null : body.get("defaultBackendName").toString());
|
||||
}
|
||||
if(body.containsKey("parameters"))
|
||||
{
|
||||
notebook.setParametersJson(body.get("parameters") == null ? null : JsonUtils.toJson(body.get("parameters")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cells from a body: [{cellKey, cellType, title, content, backendName}].
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
List<SqlWorkbenchCell> parseCells(Object raw) throws QException
|
||||
{
|
||||
List<SqlWorkbenchCell> cells = new ArrayList<>();
|
||||
if(raw == null)
|
||||
{
|
||||
return (cells);
|
||||
}
|
||||
if(!(raw instanceof List<?> list))
|
||||
{
|
||||
throw (new QBadRequestException("cells must be a list."));
|
||||
}
|
||||
if(list.size() > MAX_CELLS)
|
||||
{
|
||||
throw (new QBadRequestException("A notebook can have at most " + MAX_CELLS + " cells."));
|
||||
}
|
||||
|
||||
Set<String> keys = new HashSet<>();
|
||||
for(Object item : list)
|
||||
{
|
||||
if(!(item instanceof Map<?, ?> map))
|
||||
{
|
||||
throw (new QBadRequestException("Each cell must be an object."));
|
||||
}
|
||||
Map<String, Object> cell = (Map<String, Object>) map;
|
||||
|
||||
String type = cell.get("cellType") == null ? CELL_SQL : cell.get("cellType").toString().toUpperCase();
|
||||
if(!CELL_SQL.equals(type) && !CELL_MARKDOWN.equals(type))
|
||||
{
|
||||
throw (new QBadRequestException("cellType must be SQL or MARKDOWN."));
|
||||
}
|
||||
|
||||
String content = cell.get("content") == null ? "" : cell.get("content").toString();
|
||||
if(content.length() > config.getMaxSqlLength())
|
||||
{
|
||||
throw (new QBadRequestException("A cell is longer than the limit of " + config.getMaxSqlLength() + " characters."));
|
||||
}
|
||||
|
||||
String key = cell.get("cellKey") == null ? null : cell.get("cellKey").toString();
|
||||
if(!StringUtils.hasContent(key) || key.length() > 64 || !keys.add(key))
|
||||
{
|
||||
key = UUID.randomUUID().toString();
|
||||
keys.add(key);
|
||||
}
|
||||
|
||||
cells.add(new SqlWorkbenchCell()
|
||||
.withCellKey(key)
|
||||
.withCellType(type)
|
||||
.withTitle(truncate(cell.get("title") == null ? null : cell.get("title").toString(), 255))
|
||||
.withContent(content)
|
||||
.withBackendName(cell.get("backendName") == null ? null : cell.get("backendName").toString()));
|
||||
}
|
||||
return (cells);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> summary(SqlWorkbenchNotebook notebook, Caller caller)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("id", notebook.getId());
|
||||
view.put("title", notebook.getTitle());
|
||||
view.put("description", notebook.getDescription());
|
||||
view.put("visibility", notebook.getVisibility());
|
||||
view.put("ownerUserId", notebook.getOwnerUserId());
|
||||
view.put("ownerName", notebook.getOwnerName());
|
||||
view.put("defaultBackendName", notebook.getDefaultBackendName());
|
||||
view.put("currentVersion", notebook.getCurrentVersion());
|
||||
view.put("canEdit", Objects.equals(notebook.getOwnerUserId(), caller.userId()));
|
||||
view.put("createDate", notebook.getCreateDate() == null ? null : notebook.getCreateDate().toString());
|
||||
view.put("modifyDate", notebook.getModifyDate() == null ? null : notebook.getModifyDate().toString());
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> cellView(SqlWorkbenchCell cell)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("cellKey", cell.getCellKey());
|
||||
view.put("cellType", cell.getCellType());
|
||||
view.put("title", cell.getTitle());
|
||||
view.put("content", cell.getContent());
|
||||
view.put("backendName", cell.getBackendName());
|
||||
view.put("sortOrder", cell.getSortOrder());
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> snapshotView(SqlWorkbenchResultSnapshot snapshot)
|
||||
{
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("id", snapshot.getId());
|
||||
view.put("cellKey", snapshot.getCellKey());
|
||||
view.put("executionUuid", snapshot.getExecutionUuid());
|
||||
view.put("label", snapshot.getLabel());
|
||||
view.put("backendName", snapshot.getBackendName());
|
||||
view.put("rowCount", snapshot.getRowCount());
|
||||
view.put("truncated", snapshot.getTruncated());
|
||||
view.put("createDate", snapshot.getCreateDate() == null ? null : snapshot.getCreateDate().toString());
|
||||
view.put("result", parseJsonObject(snapshot.getResultJson()));
|
||||
return (view);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static List<Object> parseParameters(String json)
|
||||
{
|
||||
if(!StringUtils.hasContent(json))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (JsonUtils.toObject(json, new TypeReference<List<Object>>() {}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> parseJsonObject(String json)
|
||||
{
|
||||
if(!StringUtils.hasContent(json))
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (JsonUtils.toObject(json, new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void throwIfErrors(QRecord record) throws QException
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not save: " + record.getErrors().get(0).getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit, when the backend gave a transaction.
|
||||
*******************************************************************************/
|
||||
private static void commit(QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(transaction != null)
|
||||
{
|
||||
transaction.commit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String truncate(String value, int max)
|
||||
{
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max - 1) + "…");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.schema;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describes a target backend for autocomplete: schemas, tables, columns
|
||||
** (JDBC DatabaseMetaData, system schemas excluded, capped at
|
||||
** maxSchemaTables), keywords and functions, plus the instance's QQQ tables
|
||||
** on that backend with their SQL table and column names. Cached per
|
||||
** backend in the runtime.
|
||||
*******************************************************************************/
|
||||
public class SchemaIntrospector
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SchemaIntrospector.class);
|
||||
|
||||
private static final Set<String> SYSTEM_SCHEMAS = Set.of("pg_catalog", "information_schema", "pg_toast", "mysql", "performance_schema", "sys");
|
||||
|
||||
private static final List<String> CORE_KEYWORDS = List.of("SELECT", "FROM", "WHERE", "GROUP BY", "HAVING", "ORDER BY", "LIMIT", "OFFSET", "JOIN",
|
||||
"LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL OUTER JOIN", "CROSS JOIN", "ON", "USING", "AS", "DISTINCT", "UNION", "UNION ALL", "INTERSECT", "EXCEPT",
|
||||
"WITH", "CASE", "WHEN", "THEN", "ELSE", "END", "AND", "OR", "NOT", "IN", "EXISTS", "BETWEEN", "LIKE", "IS NULL", "IS NOT NULL", "ASC", "DESC",
|
||||
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "RETURNING", "CREATE TABLE", "ALTER TABLE", "DROP TABLE", "EXPLAIN");
|
||||
|
||||
private static final List<String> CORE_FUNCTIONS = List.of("count", "sum", "avg", "min", "max", "coalesce", "nullif", "cast", "lower", "upper", "trim",
|
||||
"length", "substring", "replace", "concat", "round", "floor", "ceil", "abs", "now", "current_date", "current_timestamp");
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final SqlWorkbenchRuntime runtime;
|
||||
private final WorkbenchBackends backends;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public SchemaIntrospector(QInstance qInstance, SqlWorkbenchRuntime runtime, WorkbenchBackends backends)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.runtime = runtime;
|
||||
this.backends = backends;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The schema of a backend, from cache unless refresh or stale.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> describe(String backendName, boolean refresh) throws QException
|
||||
{
|
||||
WorkbenchBackends.Target target = backends.getTarget(backendName);
|
||||
if(!refresh)
|
||||
{
|
||||
SqlWorkbenchRuntime.CachedSchema cached = runtime.getCachedSchema(target.name());
|
||||
if(cached != null)
|
||||
{
|
||||
return (cached.schema());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("backendName", target.name());
|
||||
schema.put("dialect", target.dialect().getUiName());
|
||||
|
||||
try(Connection connection = backends.openConnection(target, true))
|
||||
{
|
||||
DatabaseMetaData metaData = connection.getMetaData();
|
||||
boolean[] truncated = new boolean[1];
|
||||
schema.put("schemas", readSchemas(metaData, truncated));
|
||||
schema.put("truncated", truncated[0]);
|
||||
schema.put("keywords", keywords(metaData));
|
||||
schema.put("functions", functions(metaData));
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// driver messages can expose connection details: log them, //
|
||||
// answer generically //
|
||||
////////////////////////////////////////////////////////////////
|
||||
LOG.warn("SQL Workbench could not read a schema", e, logPair("backendName", target.name()));
|
||||
throw (new QException("Could not read the schema of backend " + target.name() + "; see the server log.", e));
|
||||
}
|
||||
|
||||
schema.put("qqqTables", qqqTables(target.name()));
|
||||
schema.put("cachedAt", runtime.getClock().instant().toString());
|
||||
runtime.putCachedSchema(target.name(), schema);
|
||||
return (schema);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Schemas, tables and columns. Schema-less databases (MySQL catalogs,
|
||||
** SQLite) report the catalog, or "main".
|
||||
*******************************************************************************/
|
||||
List<Map<String, Object>> readSchemas(DatabaseMetaData metaData, boolean[] truncated) throws SQLException
|
||||
{
|
||||
Map<String, Map<String, Map<String, Object>>> tablesBySchema = new LinkedHashMap<>();
|
||||
int count = 0;
|
||||
|
||||
try(ResultSet rs = metaData.getTables(null, null, "%", null))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
String type = String.valueOf(rs.getString("TABLE_TYPE")).toUpperCase(Locale.ROOT);
|
||||
if(!(type.equals("TABLE") || type.equals("VIEW") || type.equals("BASE TABLE") || type.equals("MATERIALIZED VIEW") || type.equals("PARTITIONED TABLE")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
String schemaName = schemaName(rs.getString("TABLE_SCHEM"), rs.getString("TABLE_CAT"));
|
||||
if(isSystemSchema(schemaName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(count >= runtime.getConfig().getMaxSchemaTables())
|
||||
{
|
||||
truncated[0] = true;
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
|
||||
Map<String, Object> table = new LinkedHashMap<>();
|
||||
table.put("name", rs.getString("TABLE_NAME"));
|
||||
table.put("type", type);
|
||||
table.put("columns", new ArrayList<Map<String, Object>>());
|
||||
tablesBySchema.computeIfAbsent(schemaName, s -> new LinkedHashMap<>()).put(rs.getString("TABLE_NAME"), table);
|
||||
}
|
||||
}
|
||||
|
||||
readColumns(metaData, tablesBySchema);
|
||||
|
||||
List<Map<String, Object>> schemas = new ArrayList<>();
|
||||
for(Map.Entry<String, Map<String, Map<String, Object>>> entry : tablesBySchema.entrySet())
|
||||
{
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("name", entry.getKey());
|
||||
schema.put("tables", new ArrayList<>(entry.getValue().values()));
|
||||
schemas.add(schema);
|
||||
}
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One getColumns call for everything, assigned to the tables kept above.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void readColumns(DatabaseMetaData metaData, Map<String, Map<String, Map<String, Object>>> tablesBySchema) throws SQLException
|
||||
{
|
||||
try(ResultSet rs = metaData.getColumns(null, null, "%", "%"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
Map<String, Map<String, Object>> tables = tablesBySchema.get(schemaName(rs.getString("TABLE_SCHEM"), rs.getString("TABLE_CAT")));
|
||||
Map<String, Object> table = tables == null ? null : tables.get(rs.getString("TABLE_NAME"));
|
||||
if(table == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> column = new LinkedHashMap<>();
|
||||
column.put("name", rs.getString("COLUMN_NAME"));
|
||||
column.put("typeName", rs.getString("TYPE_NAME"));
|
||||
column.put("nullable", rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls);
|
||||
((List<Map<String, Object>>) table.get("columns")).add(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String schemaName(String schema, String catalog)
|
||||
{
|
||||
if(StringUtils.hasContent(schema))
|
||||
{
|
||||
return (schema);
|
||||
}
|
||||
return (StringUtils.hasContent(catalog) ? catalog : "main");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static boolean isSystemSchema(String schemaName)
|
||||
{
|
||||
String lower = schemaName.toLowerCase(Locale.ROOT);
|
||||
return (SYSTEM_SCHEMAS.contains(lower) || lower.startsWith("pg_temp") || lower.startsWith("pg_toast_temp"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core keywords plus the driver's extra keywords.
|
||||
*******************************************************************************/
|
||||
static List<String> keywords(DatabaseMetaData metaData)
|
||||
{
|
||||
Set<String> keywords = new TreeSet<>(CORE_KEYWORDS);
|
||||
try
|
||||
{
|
||||
keywords.addAll(splitList(metaData.getSQLKeywords(), true));
|
||||
}
|
||||
catch(SQLException | RuntimeException e)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// optional: keep the core keywords //
|
||||
////////////////////////////////////////
|
||||
}
|
||||
return (new ArrayList<>(keywords));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core functions plus the driver's function lists.
|
||||
*******************************************************************************/
|
||||
static List<String> functions(DatabaseMetaData metaData)
|
||||
{
|
||||
Set<String> functions = new TreeSet<>(CORE_FUNCTIONS);
|
||||
try
|
||||
{
|
||||
functions.addAll(splitList(metaData.getNumericFunctions(), false));
|
||||
functions.addAll(splitList(metaData.getStringFunctions(), false));
|
||||
functions.addAll(splitList(metaData.getSystemFunctions(), false));
|
||||
functions.addAll(splitList(metaData.getTimeDateFunctions(), false));
|
||||
}
|
||||
catch(SQLException | RuntimeException e)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// optional: keep the core functions //
|
||||
////////////////////////////////////////
|
||||
}
|
||||
return (new ArrayList<>(functions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> splitList(String list, boolean upper)
|
||||
{
|
||||
if(!StringUtils.hasContent(list))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (Arrays.stream(list.split(",")).map(String::trim).filter(s -> !s.isEmpty())
|
||||
.map(s -> upper ? s.toUpperCase(Locale.ROOT) : s.toLowerCase(Locale.ROOT)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ tables stored on this backend, with their SQL names.
|
||||
*******************************************************************************/
|
||||
List<Map<String, Object>> qqqTables(String backendName)
|
||||
{
|
||||
List<Map<String, Object>> tables = new ArrayList<>();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(!backendName.equals(table.getBackendName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("name", table.getName());
|
||||
view.put("label", table.getLabel());
|
||||
view.put("sqlName", AbstractRDBMSAction.getTableName(table));
|
||||
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
Map<String, Object> fieldView = new LinkedHashMap<>();
|
||||
fieldView.put("name", field.getName());
|
||||
fieldView.put("label", field.getLabel());
|
||||
fieldView.put("sqlName", AbstractRDBMSAction.getColumnName(field));
|
||||
fieldView.put("type", field.getType() == null ? null : field.getType().name());
|
||||
fields.add(fieldView);
|
||||
}
|
||||
view.put("fields", fields);
|
||||
tables.add(view);
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A statement with its :name parameters rewritten to JDBC '?' markers, plus
|
||||
** the parameter name for each marker, in order. Values are always bound
|
||||
** through PreparedStatement, never spliced into the text.
|
||||
**
|
||||
** @param jdbcSql the statement with ? markers
|
||||
** @param parameterOrder the parameter name bound at each marker
|
||||
*******************************************************************************/
|
||||
public record NamedParameterSql(String jdbcSql, List<String> parameterOrder)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Rewrite a classified statement.
|
||||
*******************************************************************************/
|
||||
public static NamedParameterSql of(SqlStatement statement)
|
||||
{
|
||||
String text = statement.text();
|
||||
int base = statement.tokens().isEmpty() ? 0 : statement.tokens().get(0).start();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
List<String> order = new ArrayList<>();
|
||||
int at = 0;
|
||||
|
||||
for(SqlToken token : statement.tokens())
|
||||
{
|
||||
if(token.type() == SqlToken.Type.PARAMETER)
|
||||
{
|
||||
int start = token.start() - base;
|
||||
sb.append(text, at, start).append('?');
|
||||
at = token.end() - base;
|
||||
order.add(token.text());
|
||||
}
|
||||
}
|
||||
sb.append(text.substring(at));
|
||||
return (new NamedParameterSql(sb.toString(), List.copyOf(order)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Splits a SQL text into statements and classifies each one.
|
||||
**
|
||||
** Classification is defence in depth, not the security boundary: read-only
|
||||
** sessions also run inside a database READ ONLY transaction (and, when
|
||||
** configured, with read-only credentials). It is deliberately conservative:
|
||||
** anything it does not recognise as a read is a write, and a "read" that
|
||||
** mentions a mutating keyword anywhere (data-modifying CTEs, SELECT ... INTO,
|
||||
** SELECT ... FOR UPDATE) is a write.
|
||||
*******************************************************************************/
|
||||
public class SqlClassifier
|
||||
{
|
||||
private static final Set<String> READ_LEADERS = Set.of("SELECT", "WITH", "VALUES", "TABLE", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA");
|
||||
|
||||
private static final Set<String> MUTATING_WORDS = Set.of("INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "INTO", "TRUNCATE", "CREATE", "DROP", "ALTER",
|
||||
"GRANT", "REVOKE", "COPY", "CALL", "LOCK", "NEXTVAL", "SETVAL");
|
||||
|
||||
private static final Set<String> DDL_LEADERS = Set.of("CREATE", "ALTER", "DROP", "TRUNCATE", "RENAME", "COMMENT", "GRANT", "REVOKE", "REINDEX", "CLUSTER",
|
||||
"VACUUM", "ANALYZE", "OPTIMIZE", "REFRESH", "SECURITY");
|
||||
|
||||
private static final Set<String> FORBIDDEN_LEADERS = Set.of("BEGIN", "START", "COMMIT", "END", "ROLLBACK", "ABORT", "SAVEPOINT", "RELEASE", "SET", "RESET",
|
||||
"DISCARD", "LISTEN", "UNLISTEN", "NOTIFY", "PREPARE", "EXECUTE", "DEALLOCATE", "USE", "ATTACH", "DETACH", "LOAD", "SHUTDOWN", "RUNSCRIPT", "SCRIPT",
|
||||
"BACKUP", "CONNECT", "CHECKPOINT", "KILL", "FLUSH", "INSTALL", "UNINSTALL", "HANDLER", "IMPORT", "DO");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split and classify. Statements made only of comments are dropped.
|
||||
*******************************************************************************/
|
||||
public static List<SqlStatement> split(String sql, SqlDialect dialect)
|
||||
{
|
||||
String text = sql == null ? "" : sql;
|
||||
List<SqlToken> tokens = SqlScanner.scan(text, dialect);
|
||||
List<SqlStatement> result = new ArrayList<>();
|
||||
List<SqlToken> current = new ArrayList<>();
|
||||
|
||||
for(SqlToken token : tokens)
|
||||
{
|
||||
if(token.type() == SqlToken.Type.SEMICOLON)
|
||||
{
|
||||
addStatement(text, current, result);
|
||||
current = new ArrayList<>();
|
||||
}
|
||||
else
|
||||
{
|
||||
current.add(token);
|
||||
}
|
||||
}
|
||||
addStatement(text, current, result);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void addStatement(String text, List<SqlToken> tokens, List<SqlStatement> result)
|
||||
{
|
||||
if(tokens.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
String statementText = text.substring(tokens.get(0).start(), tokens.get(tokens.size() - 1).end());
|
||||
result.add(classify(statementText, List.copyOf(tokens)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classify one statement's tokens.
|
||||
*******************************************************************************/
|
||||
static SqlStatement classify(String text, List<SqlToken> tokens)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// MySQL executes /*! ... */ and /*+ ... */: fail closed, whatever //
|
||||
// the rest of the statement looks like //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
if(tokens.stream().anyMatch(t -> t.type() == SqlToken.Type.EXECUTABLE_COMMENT))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.FORBIDDEN, null,
|
||||
"MySQL executable comments (/*! ... */, /*M! ... */) and optimizer hints (/*+ ... */) run as SQL; remove them"));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// skip leading parens, as in "(SELECT ...) UNION ..." //
|
||||
////////////////////////////////////////////////////////
|
||||
int i = 0;
|
||||
while(i < tokens.size() && tokens.get(i).type() == SqlToken.Type.OPEN_PAREN)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
if(i >= tokens.size() || tokens.get(i).type() != SqlToken.Type.WORD)
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.WRITE, null, "does not start with a keyword"));
|
||||
}
|
||||
|
||||
String leader = tokens.get(i).keyword();
|
||||
|
||||
if(FORBIDDEN_LEADERS.contains(leader))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.FORBIDDEN, leader, leader + " controls the transaction or session, which the workbench manages"));
|
||||
}
|
||||
|
||||
if(DDL_LEADERS.contains(leader))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.DDL, leader, leader + " changes the schema"));
|
||||
}
|
||||
|
||||
if(!READ_LEADERS.contains(leader))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.WRITE, leader, leader + " is not a read-only statement"));
|
||||
}
|
||||
|
||||
if("PRAGMA".equals(leader))
|
||||
{
|
||||
boolean assigns = tokens.stream().anyMatch(t -> t.type() == SqlToken.Type.SYMBOL && "=".equals(t.text()));
|
||||
return (assigns
|
||||
? new SqlStatement(text, tokens, StatementKind.FORBIDDEN, leader, "PRAGMA assignments change connection state")
|
||||
: new SqlStatement(text, tokens, StatementKind.READ, leader, "read-only PRAGMA"));
|
||||
}
|
||||
|
||||
if("EXPLAIN".equals(leader) && isExplainAnalyze(tokens, i + 1))
|
||||
{
|
||||
return (new SqlStatement(text, tokens, StatementKind.EXPLAIN_ANALYZE, leader, "EXPLAIN ANALYZE executes the statement"));
|
||||
}
|
||||
|
||||
for(int j = i + 1; j < tokens.size(); j++)
|
||||
{
|
||||
String word = tokens.get(j).keyword();
|
||||
if(word != null && MUTATING_WORDS.contains(word))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// "IS DISTINCT FROM"-style false positives are not a concern: //
|
||||
// none of these words is an operator. nextval/setval only count //
|
||||
// when called, i.e. followed by '('. //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
if(("NEXTVAL".equals(word) || "SETVAL".equals(word)) && !(j + 1 < tokens.size() && tokens.get(j + 1).type() == SqlToken.Type.OPEN_PAREN))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
StatementKind kind = Set.of("CREATE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE").contains(word) ? StatementKind.DDL : StatementKind.WRITE;
|
||||
return (new SqlStatement(text, tokens, kind, leader, leader + " statement contains " + word));
|
||||
}
|
||||
}
|
||||
|
||||
return (new SqlStatement(text, tokens, StatementKind.READ, leader, "read-only " + leader));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** EXPLAIN ANALYZE ..., EXPLAIN (ANALYZE[, ...]) ..., EXPLAIN (... ANALYZE
|
||||
** true ...) - but not EXPLAIN (ANALYZE false) or EXPLAIN QUERY PLAN.
|
||||
*******************************************************************************/
|
||||
private static boolean isExplainAnalyze(List<SqlToken> tokens, int start)
|
||||
{
|
||||
if(start >= tokens.size())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
SqlToken next = tokens.get(start);
|
||||
if(next.isKeyword("ANALYZE") || next.isKeyword("ANALYSE"))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
if(next.type() == SqlToken.Type.OPEN_PAREN)
|
||||
{
|
||||
for(int j = start + 1; j < tokens.size() && tokens.get(j).type() != SqlToken.Type.CLOSE_PAREN; j++)
|
||||
{
|
||||
if(tokens.get(j).isKeyword("ANALYZE") || tokens.get(j).isKeyword("ANALYSE"))
|
||||
{
|
||||
SqlToken value = j + 1 < tokens.size() ? tokens.get(j + 1) : null;
|
||||
boolean off = value != null && (value.isKeyword("FALSE") || value.isKeyword("OFF") || "0".equals(value.text()));
|
||||
return (!off);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SQL dialects the workbench understands. The dialect drives lexing
|
||||
** (comment and quoting rules), read-only enforcement, EXPLAIN syntax, and
|
||||
** whether a DDL dry run can be rolled back.
|
||||
**
|
||||
** The UI receives the dialect's name and maps it to its editor and formatter
|
||||
** dialects (postgresql, mysql, sqlite, sql).
|
||||
*******************************************************************************/
|
||||
public enum SqlDialect
|
||||
{
|
||||
POSTGRES("postgresql", true, false, false, true, false),
|
||||
MYSQL("mysql", false, true, true, false, true),
|
||||
SQLITE("sqlite", true, false, false, false, true),
|
||||
H2("h2", false, false, false, false, false),
|
||||
GENERIC("sql", false, false, false, false, false);
|
||||
|
||||
private final String uiName;
|
||||
private final boolean transactionalDdl;
|
||||
private final boolean hashComments;
|
||||
private final boolean backslashEscapes;
|
||||
private final boolean dollarQuotes;
|
||||
private final boolean backtickIdentifiers;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
SqlDialect(String uiName, boolean transactionalDdl, boolean hashComments, boolean backslashEscapes, boolean dollarQuotes, boolean backtickIdentifiers)
|
||||
{
|
||||
this.uiName = uiName;
|
||||
this.transactionalDdl = transactionalDdl;
|
||||
this.hashComments = hashComments;
|
||||
this.backslashEscapes = backslashEscapes;
|
||||
this.dollarQuotes = dollarQuotes;
|
||||
this.backtickIdentifiers = backtickIdentifiers;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a QQQ RDBMS vendor string (postgres, aurora-postgres, mysql,
|
||||
** aurora-mysql, sqlite, h2) to a dialect; anything else is GENERIC.
|
||||
*******************************************************************************/
|
||||
public static SqlDialect fromVendor(String vendor)
|
||||
{
|
||||
if(vendor == null)
|
||||
{
|
||||
return (GENERIC);
|
||||
}
|
||||
|
||||
String v = vendor.toLowerCase(Locale.ROOT);
|
||||
if(v.contains("postgres"))
|
||||
{
|
||||
return (POSTGRES);
|
||||
}
|
||||
if(v.contains("mysql") || v.contains("mariadb"))
|
||||
{
|
||||
return (MYSQL);
|
||||
}
|
||||
if(v.contains("sqlite"))
|
||||
{
|
||||
return (SQLITE);
|
||||
}
|
||||
if(v.contains("h2"))
|
||||
{
|
||||
return (H2);
|
||||
}
|
||||
return (GENERIC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Name the UI uses to pick its editor and formatter dialect.
|
||||
*******************************************************************************/
|
||||
public String getUiName()
|
||||
{
|
||||
return (uiName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether DDL runs inside a transaction (and so can be dry-run and rolled
|
||||
** back). MySQL and H2 commit implicitly on DDL.
|
||||
*******************************************************************************/
|
||||
public boolean isTransactionalDdl()
|
||||
{
|
||||
return (transactionalDdl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether '#' starts a line comment.
|
||||
*******************************************************************************/
|
||||
public boolean isHashComments()
|
||||
{
|
||||
return (hashComments);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether backslash escapes the next character inside string literals.
|
||||
*******************************************************************************/
|
||||
public boolean isBackslashEscapes()
|
||||
{
|
||||
return (backslashEscapes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether $tag$...$tag$ dollar-quoted strings exist.
|
||||
*******************************************************************************/
|
||||
public boolean isDollarQuotes()
|
||||
{
|
||||
return (dollarQuotes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether `identifier` quoting exists.
|
||||
*******************************************************************************/
|
||||
public boolean isBacktickIdentifiers()
|
||||
{
|
||||
return (backtickIdentifiers);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A small, dialect-aware SQL scanner. It is not a parser: it only knows
|
||||
** enough to find comments, string literals, quoted identifiers, dollar
|
||||
** quotes, statement separators and :named parameters, so that
|
||||
** classification and parameter binding never look inside a literal or a
|
||||
** comment.
|
||||
**
|
||||
** Unterminated literals and comments run to the end of the text (the
|
||||
** database reports the syntax error); the scanner never throws.
|
||||
**
|
||||
** MySQL executes some "comments": /*!... and /*M!... comments run their
|
||||
** contents as SQL, and /*+... carries optimizer hints. On the MySQL
|
||||
** dialect these become EXECUTABLE_COMMENT tokens (which the classifier
|
||||
** forbids) instead of being skipped. MySQL also needs whitespace after
|
||||
** "--" for a line comment ("1--1" is arithmetic), so on MySQL "--"
|
||||
** followed by anything else is scanned as symbols.
|
||||
*******************************************************************************/
|
||||
public class SqlScanner
|
||||
{
|
||||
private final String sql;
|
||||
private final SqlDialect dialect;
|
||||
private final int length;
|
||||
|
||||
private int pos = 0;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlScanner(String sql, SqlDialect dialect)
|
||||
{
|
||||
this.sql = sql == null ? "" : sql;
|
||||
this.dialect = dialect == null ? SqlDialect.GENERIC : dialect;
|
||||
this.length = this.sql.length();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan a SQL text into significant tokens.
|
||||
*******************************************************************************/
|
||||
public static List<SqlToken> scan(String sql, SqlDialect dialect)
|
||||
{
|
||||
return (new SqlScanner(sql, dialect).scanAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<SqlToken> scanAll()
|
||||
{
|
||||
List<SqlToken> tokens = new ArrayList<>();
|
||||
while(pos < length)
|
||||
{
|
||||
char c = sql.charAt(pos);
|
||||
|
||||
if(Character.isWhitespace(c))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
else if(c == '-' && peek(1) == '-' && isDashCommentStart())
|
||||
{
|
||||
skipLineComment();
|
||||
}
|
||||
else if(c == '#' && dialect.isHashComments())
|
||||
{
|
||||
skipLineComment();
|
||||
}
|
||||
else if(c == '/' && peek(1) == '*' && isExecutableCommentStart())
|
||||
{
|
||||
int start = pos;
|
||||
skipBlockComment();
|
||||
tokens.add(new SqlToken(SqlToken.Type.EXECUTABLE_COMMENT, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else if(c == '/' && peek(1) == '*')
|
||||
{
|
||||
skipBlockComment();
|
||||
}
|
||||
else if(c == '\'')
|
||||
{
|
||||
tokens.add(readQuoted(SqlToken.Type.STRING, '\'', dialect.isBackslashEscapes(), pos));
|
||||
}
|
||||
else if((c == 'E' || c == 'e') && peek(1) == '\'' && dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
int start = pos;
|
||||
pos++;
|
||||
tokens.add(readQuoted(SqlToken.Type.STRING, '\'', true, start));
|
||||
}
|
||||
else if(c == '"')
|
||||
{
|
||||
tokens.add(readQuoted(dialect == SqlDialect.MYSQL ? SqlToken.Type.STRING : SqlToken.Type.QUOTED_IDENTIFIER, '"', dialect.isBackslashEscapes() && dialect == SqlDialect.MYSQL, pos));
|
||||
}
|
||||
else if(c == '`' && dialect.isBacktickIdentifiers())
|
||||
{
|
||||
tokens.add(readQuoted(SqlToken.Type.QUOTED_IDENTIFIER, '`', false, pos));
|
||||
}
|
||||
else if(c == '[' && dialect == SqlDialect.SQLITE)
|
||||
{
|
||||
int start = pos;
|
||||
int close = sql.indexOf(']', pos + 1);
|
||||
pos = close < 0 ? length : close + 1;
|
||||
tokens.add(new SqlToken(SqlToken.Type.QUOTED_IDENTIFIER, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else if(c == '$' && dialect.isDollarQuotes() && isDollarQuoteStart())
|
||||
{
|
||||
tokens.add(readDollarQuoted());
|
||||
}
|
||||
else if(c == ';')
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.SEMICOLON, ";", pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
else if(c == '(')
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.OPEN_PAREN, "(", pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
else if(c == ')')
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.CLOSE_PAREN, ")", pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
else if(c == ':' && peek(1) == ':')
|
||||
{
|
||||
/////////////////////////////////////////
|
||||
// postgres cast operator, not a param //
|
||||
/////////////////////////////////////////
|
||||
tokens.add(new SqlToken(SqlToken.Type.SYMBOL, "::", pos, pos + 2));
|
||||
pos += 2;
|
||||
}
|
||||
else if(c == ':' && isIdentifierStart(peek(1)) && !isPreviousCharColon())
|
||||
{
|
||||
int start = pos;
|
||||
pos++;
|
||||
int nameStart = pos;
|
||||
while(pos < length && isIdentifierPart(sql.charAt(pos)))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
tokens.add(new SqlToken(SqlToken.Type.PARAMETER, sql.substring(nameStart, pos), start, pos));
|
||||
}
|
||||
else if(isIdentifierStart(c))
|
||||
{
|
||||
int start = pos;
|
||||
while(pos < length && (isIdentifierPart(sql.charAt(pos)) || sql.charAt(pos) == '$'))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
tokens.add(new SqlToken(SqlToken.Type.WORD, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else if(Character.isDigit(c))
|
||||
{
|
||||
int start = pos;
|
||||
while(pos < length && (Character.isLetterOrDigit(sql.charAt(pos)) || sql.charAt(pos) == '.'))
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
tokens.add(new SqlToken(SqlToken.Type.NUMBER, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
else
|
||||
{
|
||||
tokens.add(new SqlToken(SqlToken.Type.SYMBOL, String.valueOf(c), pos, pos + 1));
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
return (tokens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private char peek(int offset)
|
||||
{
|
||||
int i = pos + offset;
|
||||
return (i < length ? sql.charAt(i) : '\0');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** At "--": a line comment everywhere but MySQL, where the dashes must be
|
||||
** followed by whitespace, a control character, or the end of the text.
|
||||
*******************************************************************************/
|
||||
private boolean isDashCommentStart()
|
||||
{
|
||||
if(dialect != SqlDialect.MYSQL)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
char next = peek(2);
|
||||
return (next == '\0' || Character.isWhitespace(next) || Character.isISOControl(next));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** At "/*" on MySQL: /*!, /*M! or /*+, which the server executes.
|
||||
*******************************************************************************/
|
||||
private boolean isExecutableCommentStart()
|
||||
{
|
||||
if(dialect != SqlDialect.MYSQL)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
char third = peek(2);
|
||||
return (third == '!' || third == '+' || (third == 'M' && peek(3) == '!'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private boolean isPreviousCharColon()
|
||||
{
|
||||
return (pos > 0 && sql.charAt(pos - 1) == ':');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isIdentifierStart(char c)
|
||||
{
|
||||
return (Character.isLetter(c) || c == '_');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isIdentifierPart(char c)
|
||||
{
|
||||
return (Character.isLetterOrDigit(c) || c == '_');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void skipLineComment()
|
||||
{
|
||||
while(pos < length && sql.charAt(pos) != '\n')
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Block comments; postgres nests them, the others do not.
|
||||
*******************************************************************************/
|
||||
private void skipBlockComment()
|
||||
{
|
||||
int depth = 0;
|
||||
while(pos < length)
|
||||
{
|
||||
if(sql.charAt(pos) == '/' && peek(1) == '*')
|
||||
{
|
||||
if(depth == 0 || dialect == SqlDialect.POSTGRES)
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
pos += 2;
|
||||
}
|
||||
else if(sql.charAt(pos) == '*' && peek(1) == '/')
|
||||
{
|
||||
depth--;
|
||||
pos += 2;
|
||||
if(depth == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a quoted run starting at the quote character at pos; a doubled
|
||||
** quote is an escaped quote.
|
||||
*******************************************************************************/
|
||||
private SqlToken readQuoted(SqlToken.Type type, char quote, boolean backslashEscapes, int tokenStart)
|
||||
{
|
||||
pos++;
|
||||
while(pos < length)
|
||||
{
|
||||
char c = sql.charAt(pos);
|
||||
if(backslashEscapes && c == '\\')
|
||||
{
|
||||
pos += 2;
|
||||
}
|
||||
else if(c == quote)
|
||||
{
|
||||
if(peek(1) == quote)
|
||||
{
|
||||
pos += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
pos++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
pos = Math.min(pos, length);
|
||||
return (new SqlToken(type, sql.substring(tokenStart, pos), tokenStart, pos));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** $$ or $tag$ where tag is an identifier (and not a $1 positional param).
|
||||
*******************************************************************************/
|
||||
private boolean isDollarQuoteStart()
|
||||
{
|
||||
if(pos > 0 && isIdentifierPart(sql.charAt(pos - 1)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int i = pos + 1;
|
||||
if(i < length && sql.charAt(i) == '$')
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(i >= length || !isIdentifierStart(sql.charAt(i)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
while(i < length && isIdentifierPart(sql.charAt(i)))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
return (i < length && sql.charAt(i) == '$');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SqlToken readDollarQuoted()
|
||||
{
|
||||
int start = pos;
|
||||
int tagClose = sql.indexOf('$', pos + 1);
|
||||
String tag = sql.substring(pos, tagClose + 1);
|
||||
int close = sql.indexOf(tag, tagClose + 1);
|
||||
pos = close < 0 ? length : close + tag.length();
|
||||
return (new SqlToken(SqlToken.Type.STRING, sql.substring(start, pos), start, pos));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One statement of a SQL text, with its classification.
|
||||
**
|
||||
** @param text the statement's source text (no trailing ';')
|
||||
** @param tokens its significant tokens (offsets are into the full text)
|
||||
** @param kind its classification
|
||||
** @param leadingKeyword its first keyword, upper case (null if none)
|
||||
** @param reason why it got this kind, for messages
|
||||
*******************************************************************************/
|
||||
public record SqlStatement(String text, List<SqlToken> tokens, StatementKind kind, String leadingKeyword, String reason)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Names of the :parameters this statement uses, in order of appearance
|
||||
** (repeats included).
|
||||
*******************************************************************************/
|
||||
public List<String> parameterNames()
|
||||
{
|
||||
return (tokens.stream().filter(t -> t.type() == SqlToken.Type.PARAMETER).map(SqlToken::text).toList());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One significant token of a SQL text. Comments and whitespace produce no
|
||||
** tokens; start and end are offsets into the original text (end exclusive).
|
||||
**
|
||||
** @param type kind of token
|
||||
** @param text the token's source text (for PARAMETER, the name without ':')
|
||||
** @param start offset of the token's first character
|
||||
** @param end offset after the token's last character
|
||||
*******************************************************************************/
|
||||
public record SqlToken(Type type, String text, int start, int end)
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Token kinds.
|
||||
*******************************************************************************/
|
||||
public enum Type
|
||||
{
|
||||
WORD,
|
||||
QUOTED_IDENTIFIER,
|
||||
STRING,
|
||||
NUMBER,
|
||||
PARAMETER,
|
||||
SEMICOLON,
|
||||
OPEN_PAREN,
|
||||
CLOSE_PAREN,
|
||||
SYMBOL,
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// a MySQL / MariaDB comment the server executes: /*! ... */, //
|
||||
// /*M! ... */ (version-conditional SQL) or /*+ ... */ (optimizer //
|
||||
// hints). Its contents are SQL, so it is a token, not a comment. //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
EXECUTABLE_COMMENT
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Upper-cased text of a WORD token, or null for any other token.
|
||||
*******************************************************************************/
|
||||
public String keyword()
|
||||
{
|
||||
return (type == Type.WORD ? text.toUpperCase(Locale.ROOT) : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this is a WORD equal (ignoring case) to the given keyword.
|
||||
*******************************************************************************/
|
||||
public boolean isKeyword(String keyword)
|
||||
{
|
||||
return (type == Type.WORD && text.equalsIgnoreCase(keyword));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a statement does, as far as the workbench's gating is concerned.
|
||||
**
|
||||
** READ - SELECT / WITH / VALUES / TABLE / SHOW / DESCRIBE /
|
||||
** EXPLAIN (without ANALYZE) / read-only PRAGMA. Allowed
|
||||
** in read-only sessions.
|
||||
** EXPLAIN_ANALYZE - EXPLAIN ANALYZE executes its statement: needs the
|
||||
** write permission and an explicit request.
|
||||
** WRITE - DML (INSERT, UPDATE, DELETE, MERGE, ...). Write mode.
|
||||
** DDL - CREATE, ALTER, DROP, TRUNCATE, GRANT, ... Write mode;
|
||||
** dry run only where DDL is transactional.
|
||||
** FORBIDDEN - transaction and session control (BEGIN, COMMIT, SET,
|
||||
** USE, ATTACH, ...) and file/system commands. The engine
|
||||
** owns transactions and session state, so these are
|
||||
** refused in every mode.
|
||||
*******************************************************************************/
|
||||
public enum StatementKind
|
||||
{
|
||||
READ,
|
||||
EXPLAIN_ANALYZE,
|
||||
WRITE,
|
||||
DDL,
|
||||
FORBIDDEN;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a statement of this kind may run in a read-only session.
|
||||
*******************************************************************************/
|
||||
public boolean isReadOnlySafe()
|
||||
{
|
||||
return (this == READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a statement of this kind changes data or schema.
|
||||
*******************************************************************************/
|
||||
public boolean isMutating()
|
||||
{
|
||||
return (this == WRITE || this == DDL);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test base: an H2 (MODE=MySQL) backend "h2" holding the qbit's tables
|
||||
** (snake_case, as hosts map them) and a "person" table, the qbit produced
|
||||
** into a QInstance, and helpers for sessions and callers.
|
||||
*******************************************************************************/
|
||||
public class BaseSqlWorkbenchTest
|
||||
{
|
||||
public static final String BACKEND_NAME = "h2";
|
||||
public static final String READER = "reader-1";
|
||||
public static final String WRITER = "writer-1";
|
||||
public static final String OTHER_READER = "reader-2";
|
||||
public static final String ADMIN = "admin-1";
|
||||
|
||||
protected QInstance qInstance;
|
||||
protected SqlWorkbenchQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The H2 backend.
|
||||
*******************************************************************************/
|
||||
public static RDBMSBackendMetaData defineH2Backend(String name)
|
||||
{
|
||||
RDBMSBackendMetaData backend = new RDBMSBackendMetaData()
|
||||
.withName(name)
|
||||
.withVendor("h2")
|
||||
.withHostName("mem")
|
||||
.withDatabaseName("sqlwb_" + name)
|
||||
.withUsername("sa");
|
||||
backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE");
|
||||
return (backend);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The snake_case customizer hosts use for the qbit's tables.
|
||||
*******************************************************************************/
|
||||
public static class SnakeCaseCustomizer implements MetaDataCustomizerInterface<QTableMetaData>
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table)
|
||||
{
|
||||
table.setBackendDetails(new RDBMSTableBackendDetails().withTableName(QInstanceEnricher.inferBackendName(table.getName())));
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A default config for tests.
|
||||
*******************************************************************************/
|
||||
protected SqlWorkbenchQBitConfig defineConfig()
|
||||
{
|
||||
return (new SqlWorkbenchQBitConfig()
|
||||
.withBackendName(BACKEND_NAME)
|
||||
.withTableMetaDataCustomizer(new SnakeCaseCustomizer()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the instance, prime the database, init the context as a reader.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void baseSetUp() throws Exception
|
||||
{
|
||||
qInstance = new QInstance();
|
||||
qInstance.addBackend(defineH2Backend(BACKEND_NAME));
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(TestSessionCustomizer.class)));
|
||||
|
||||
QTableMetaData person = new QTableMetaData()
|
||||
.withName("person")
|
||||
.withLabel("Person")
|
||||
.withBackendName(BACKEND_NAME)
|
||||
.withPrimaryKeyField("id")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("person"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("firstName", QFieldType.STRING).withBackendName("first_name").withLabel("First Name"))
|
||||
.withField(new QFieldMetaData("lastName", QFieldType.STRING).withBackendName("last_name"));
|
||||
qInstance.addTable(person);
|
||||
|
||||
config = defineConfig();
|
||||
customizeConfig(config);
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
|
||||
primeDatabase(qInstance, BACKEND_NAME, "sql-workbench-h2.sql");
|
||||
loginAs(READER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hook for subclasses to change the config before production.
|
||||
*******************************************************************************/
|
||||
protected void customizeConfig(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void baseTearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a SQL script (statements split on ';' at line ends).
|
||||
*******************************************************************************/
|
||||
public static void primeDatabase(QInstance qInstance, String backendName, String resource) throws Exception
|
||||
{
|
||||
String script;
|
||||
try(InputStream in = BaseSqlWorkbenchTest.class.getClassLoader().getResourceAsStream(resource))
|
||||
{
|
||||
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
List<String> statements = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
for(String line : script.split("\n"))
|
||||
{
|
||||
if(line.trim().startsWith("--"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
current.append(line).append('\n');
|
||||
if(line.trim().endsWith(";"))
|
||||
{
|
||||
statements.add(current.toString().trim().replaceAll(";$", ""));
|
||||
current.setLength(0);
|
||||
}
|
||||
}
|
||||
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(backendName)); Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : statements)
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Init QContext with a session for one of the test users.
|
||||
*******************************************************************************/
|
||||
protected QSession loginAs(String userId)
|
||||
{
|
||||
QSession session = TestSessionCustomizer.sessionFor(userId);
|
||||
QContext.init(qInstance, session);
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The caller for a test user (permissions as TestSessionCustomizer grants).
|
||||
*******************************************************************************/
|
||||
protected Caller caller(String userId)
|
||||
{
|
||||
boolean writer = userId.startsWith("writer");
|
||||
return (new Caller(userId, "Name of " + userId, writer, writer, "127.0.0.1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Grants permissions by the session id's prefix: "writer-*" gets access,
|
||||
** write and share; "reader-*" gets access; "admin-*" gets access and the
|
||||
** admin permission (the generic table UI / API); anything else nothing.
|
||||
** The user id is the session id.
|
||||
*******************************************************************************/
|
||||
public static class TestSessionCustomizer implements com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
QSession granted = sessionFor(qSession.getUuid());
|
||||
qSession.setUser(granted.getUser());
|
||||
qSession.setPermissions(granted.getPermissions());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static QSession sessionFor(String userId)
|
||||
{
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Name of " + userId));
|
||||
session.setPermissions(new java.util.HashSet<>());
|
||||
if(userId != null && (userId.startsWith("reader") || userId.startsWith("writer") || userId.startsWith("admin")))
|
||||
{
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_ACCESS_PERMISSION_BASE_NAME + ".hasAccess");
|
||||
}
|
||||
if(userId != null && userId.startsWith("admin"))
|
||||
{
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_ADMIN_PERMISSION_BASE_NAME + ".hasAccess");
|
||||
}
|
||||
if(userId != null && userId.startsWith("writer"))
|
||||
{
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_WRITE_PERMISSION_NAME);
|
||||
session.withPermission(SqlWorkbenchQBitConfig.DEFAULT_SHARE_PERMISSION_NAME);
|
||||
}
|
||||
return (session);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionRequest;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.SessionGuard;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.SqlExecutionEngine;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.notebook.NotebookService;
|
||||
import com.kingsrook.qbits.sqlworkbench.schema.SchemaIntrospector;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.C3P0PooledConnectionProvider;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.ConnectionPoolSettings;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The engine against a real PostgreSQL (Testcontainers): read-only
|
||||
** transactions, timeouts, cancellation, clean pooled connections,
|
||||
** read-only credentials, write preview / commit, transactional DDL dry
|
||||
** runs, EXPLAIN (ANALYZE) with rollback, types, schema, and notebooks.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Testcontainers
|
||||
class PostgresIntegrationTest
|
||||
{
|
||||
private static final String BACKEND = "pg";
|
||||
|
||||
@Container
|
||||
@SuppressWarnings("resource")
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse(System.getProperty("postgres.test.image", "postgres:16-alpine"))
|
||||
.asCompatibleSubstituteFor("postgres"));
|
||||
|
||||
private QInstance qInstance;
|
||||
private SqlWorkbenchQBitConfig config;
|
||||
private SqlExecutionEngine engine;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Schema, data, and a SELECT-only role, once.
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void primeDatabase() throws Exception
|
||||
{
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute(Files.readString(Path.of("docs/ddl/postgres.sql")));
|
||||
statement.execute("""
|
||||
CREATE TABLE person (id SERIAL PRIMARY KEY, first_name TEXT, salary NUMERIC(12,2), tags TEXT[], doc JSONB, ref UUID, seen TIMESTAMPTZ);
|
||||
INSERT INTO person (first_name, salary, tags, doc, ref, seen) VALUES
|
||||
('Darin', 25000, ARRAY['a','b'], '{"k": 1}', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', '2026-01-01T10:00:00Z'),
|
||||
('James', 26000, NULL, NULL, NULL, NULL),
|
||||
('Tim', NULL, ARRAY[]::TEXT[], '[1,2]', NULL, NULL);
|
||||
CREATE ROLE sqlwb_ro LOGIN PASSWORD 'ro-pass';
|
||||
GRANT SELECT ON person TO sqlwb_ro;
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
ConnectionManager.resetConnectionProviders();
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("TRUNCATE sql_workbench_result_snapshot, sql_workbench_notebook_version, sql_workbench_cell, sql_workbench_notebook, sql_workbench_execution");
|
||||
statement.execute("DROP TABLE IF EXISTS scratch");
|
||||
}
|
||||
|
||||
PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData()
|
||||
.withName(BACKEND)
|
||||
.withHostName(POSTGRES.getHost())
|
||||
.withPort(POSTGRES.getFirstMappedPort())
|
||||
.withDatabaseName(POSTGRES.getDatabaseName())
|
||||
.withUsername(POSTGRES.getUsername())
|
||||
.withPassword(POSTGRES.getPassword());
|
||||
backend.setConnectionProvider(new QCodeReference(C3P0PooledConnectionProvider.class));
|
||||
backend.setConnectionPoolSettings(new ConnectionPoolSettings().withInitialPoolSize(1).withMinPoolSize(1).withMaxPoolSize(1));
|
||||
|
||||
qInstance = new QInstance();
|
||||
qInstance.addBackend(backend);
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
|
||||
config = new SqlWorkbenchQBitConfig().withBackendName(BACKEND).withTableMetaDataCustomizer(new BaseSqlWorkbenchTest.SnakeCaseCustomizer()).withRegisterRoutes(false);
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
QContext.init(qInstance, BaseSqlWorkbenchTest.TestSessionCustomizer.sessionFor("writer-1"));
|
||||
engine = new SqlExecutionEngine(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
ConnectionManager.resetConnectionProviders();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Caller reader()
|
||||
{
|
||||
return (new Caller("reader-1", "Reader", false, false, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Caller writer()
|
||||
{
|
||||
return (new Caller("writer-1", "Writer", true, true, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> read(String sql) throws Exception
|
||||
{
|
||||
return (engine.execute(ExecutionRequest.readOnly(BACKEND, sql, Map.of()), reader()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The database itself refuses writes in a workbench read-only session,
|
||||
** even for SQL the classifier would never let through.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReadOnlyTransactionIsEnforcedByPostgres() throws Exception
|
||||
{
|
||||
Connection connection = ConnectionManager.getConnection((PostgreSQLBackendMetaData) qInstance.getBackend(BACKEND));
|
||||
try(SessionGuard guard = new SessionGuard(connection, SqlDialect.POSTGRES, true, 5).begin(); Statement statement = guard.getConnection().createStatement())
|
||||
{
|
||||
assertThatThrownBy(() -> statement.execute("INSERT INTO person (first_name) VALUES ('nope')"))
|
||||
.isInstanceOf(SQLException.class).hasMessageContaining("read-only transaction");
|
||||
}
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM person WHERE first_name = 'nope'")).isZero();
|
||||
|
||||
assertThatThrownBy(() -> read("WITH d AS (DELETE FROM person RETURNING *) SELECT * FROM d")).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> read("SELECT nextval('person_id_seq')")).isInstanceOf(QBadRequestException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pool of one connection: whatever a session did, the next borrower gets
|
||||
** autocommit on, read-write, and no statement_timeout.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPooledConnectionIsReturnedClean() throws Exception
|
||||
{
|
||||
assertThat(read("SELECT count(*) FROM person").get("status")).isEqualTo("SUCCEEDED");
|
||||
config.setStatementTimeoutSeconds(1);
|
||||
assertThat(read("SELECT pg_sleep(3)").get("status")).isEqualTo("TIMED_OUT");
|
||||
|
||||
try(Connection connection = ConnectionManager.getConnection((PostgreSQLBackendMetaData) qInstance.getBackend(BACKEND)); Statement statement = connection.createStatement())
|
||||
{
|
||||
assertThat(connection.getAutoCommit()).isTrue();
|
||||
assertThat(connection.isReadOnly()).isFalse();
|
||||
try(ResultSet rs = statement.executeQuery("SELECT current_setting('transaction_read_only'), current_setting('statement_timeout')"))
|
||||
{
|
||||
rs.next();
|
||||
assertThat(rs.getString(1)).isEqualTo("off");
|
||||
assertThat(rs.getString(2)).isEqualTo("0");
|
||||
}
|
||||
statement.execute("INSERT INTO person (first_name) VALUES ('after')");
|
||||
statement.execute("DELETE FROM person WHERE first_name = 'after'");
|
||||
}
|
||||
|
||||
List<SqlWorkbenchExecution> audits = QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter());
|
||||
assertThat(audits).extracting(SqlWorkbenchExecution::getStatus).contains("SUCCEEDED", "TIMED_OUT");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCancel() throws Exception
|
||||
{
|
||||
SqlWorkbenchRuntime runtime = SqlWorkbenchRuntime.get(qInstance);
|
||||
var captured = QContext.capture();
|
||||
CompletableFuture<Map<String, Object>> future = CompletableFuture.supplyAsync(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.init(captured);
|
||||
return (read("SELECT pg_sleep(20)"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new RuntimeException(e));
|
||||
}
|
||||
});
|
||||
|
||||
String uuid = null;
|
||||
for(int i = 0; i < 100 && uuid == null; i++)
|
||||
{
|
||||
Thread.sleep(100);
|
||||
uuid = QueryCount.string("SELECT execution_uuid FROM sql_workbench_execution WHERE status = 'RUNNING'");
|
||||
}
|
||||
assertThat(uuid).isNotNull();
|
||||
Thread.sleep(300);
|
||||
assertThat(engine.cancel(uuid, reader())).isTrue();
|
||||
assertThat(future.get(10, TimeUnit.SECONDS).get("status")).isEqualTo("CANCELLED");
|
||||
assertThat(runtime.getRunningStatements().size()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReadOnlyCredentials() throws Exception
|
||||
{
|
||||
config.withReadOnlyCredentials(BACKEND, new SqlWorkbenchCredentials().withUsername("sqlwb_ro").withPassword("ro-pass"));
|
||||
assertThat(read("SELECT current_user").get("rows").toString()).contains("sqlwb_ro");
|
||||
assertThat(read("SELECT count(*) FROM person").get("status")).isEqualTo("SUCCEEDED");
|
||||
|
||||
Map<String, Object> denied = read("SELECT * FROM sql_workbench_execution");
|
||||
assertThat(denied.get("status")).isEqualTo("FAILED");
|
||||
assertThat((String) denied.get("error")).containsIgnoringCase("permission denied");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWriteAndTransactionalDdl() throws Exception
|
||||
{
|
||||
String sql = "UPDATE person SET salary = salary + 1 WHERE salary IS NOT NULL";
|
||||
Map<String, Object> preview = engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, null, false), writer());
|
||||
assertThat(preview.get("totalAffectedRows")).isEqualTo(2L);
|
||||
Map<String, Object> commit = engine.execute(new ExecutionRequest(BACKEND, sql, Map.of(), true, null, null, null, (String) preview.get("confirmToken"), false), writer());
|
||||
assertThat(commit.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM person WHERE salary = 25001")).isEqualTo(1);
|
||||
engine.execute(new ExecutionRequest(BACKEND, "UPDATE person SET salary = salary - 1 WHERE salary IS NOT NULL", Map.of(), true, null, null, null, null, false), writer());
|
||||
|
||||
String ddl = "CREATE TABLE scratch (id INT); INSERT INTO scratch VALUES (1), (2)";
|
||||
Map<String, Object> ddlPreview = engine.execute(new ExecutionRequest(BACKEND, ddl, Map.of(), true, null, null, null, null, false), writer());
|
||||
assertThat(ddlPreview.get("dryRunSupported")).isEqualTo(true);
|
||||
assertThat(ddlPreview.get("totalAffectedRows")).isEqualTo(2L);
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM information_schema.tables WHERE table_name = 'scratch'")).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testExplainTypesSchemaAndNotebooks() throws Exception
|
||||
{
|
||||
Map<String, Object> explain = engine.explain(BACKEND, "SELECT * FROM person WHERE id = :id", Map.of("id", 1), false, reader());
|
||||
Map<String, Object> plan = (Map<String, Object>) explain.get("plan");
|
||||
assertThat(plan.get("label")).isNotNull();
|
||||
assertThat(plan.get("totalCost")).isNotNull();
|
||||
|
||||
Map<String, Object> analyzed = engine.explain(BACKEND, "DELETE FROM person WHERE id = 1", Map.of(), true, writer());
|
||||
assertThat(((Map<String, Object>) analyzed.get("plan")).get("actualTimeMs")).isNotNull();
|
||||
assertThat(QueryCount.of("SELECT count(*) FROM person WHERE id = 1")).isEqualTo(1);
|
||||
|
||||
Map<String, Object> typed = engine.execute(ExecutionRequest.readOnly(BACKEND, "SELECT tags, doc, ref, seen FROM person WHERE seen > :since", Map.of("since", "2025-01-01")), reader());
|
||||
List<Map<String, Object>> columns = (List<Map<String, Object>>) typed.get("columns");
|
||||
assertThat(columns).extracting(c -> c.get("kind")).containsExactly("array", "json", "other", "datetime");
|
||||
List<Object> row = ((List<List<Object>>) typed.get("rows")).get(0);
|
||||
assertThat(row.get(0)).isEqualTo(List.of("a", "b"));
|
||||
assertThat(row.get(1)).isEqualTo("{\"k\": 1}");
|
||||
assertThat(row.get(2)).isEqualTo("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
assertThat(java.time.OffsetDateTime.parse((String) row.get(3)).toInstant()).isEqualTo(java.time.Instant.parse("2026-01-01T10:00:00Z"));
|
||||
|
||||
Map<String, Object> schema = new SchemaIntrospector(qInstance, SqlWorkbenchRuntime.get(qInstance), engine.getBackends()).describe(BACKEND, true);
|
||||
assertThat(schema.get("dialect")).isEqualTo("postgresql");
|
||||
assertThat(schema.toString()).contains("name=public").contains("name=person").contains("first_name").doesNotContain("pg_catalog");
|
||||
|
||||
NotebookService notebooks = new NotebookService(SqlWorkbenchRuntime.get(qInstance));
|
||||
Integer id = (Integer) notebooks.create(Map.of("title", "PG", "cells", List.of(Map.of("cellType", "SQL", "content", "SELECT 1"))), writer()).get("id");
|
||||
notebooks.update(id, Map.of("title", "PG 2"), writer());
|
||||
assertThat(notebooks.versions(id, writer())).hasSize(2);
|
||||
notebooks.delete(id, writer());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One-value queries against the container as the admin user.
|
||||
*******************************************************************************/
|
||||
static class QueryCount
|
||||
{
|
||||
/*******************************************************************************
|
||||
** First column of the first row as a string, or null if no rows.
|
||||
*******************************************************************************/
|
||||
static String string(String sql) throws SQLException
|
||||
{
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
return (rs.next() ? rs.getString(1) : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static int of(String sql) throws SQLException
|
||||
{
|
||||
try(Connection connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
return (rs.getInt(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import org.junit.jupiter.api.extension.ExecutionCondition;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JUnit condition for integration tests that need Docker.
|
||||
**
|
||||
** Without Docker, the tests are skipped on a developer machine, but fail when
|
||||
** CI=true, so a CI build can never pass with its integration tests silently
|
||||
** skipped. Register it ahead of @Testcontainers, so no container is started
|
||||
** when the tests are disabled.
|
||||
*******************************************************************************/
|
||||
public class RequiresDockerCondition implements ExecutionCondition
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate against the local Docker environment and the CI env var.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context)
|
||||
{
|
||||
return (evaluate(DockerClientFactory.instance().isDockerAvailable(), System.getenv("CI")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decide whether Docker-backed tests run, skip, or fail.
|
||||
**
|
||||
** @param dockerAvailable whether a usable Docker environment was found
|
||||
** @param ciValue value of the CI environment variable (may be null)
|
||||
** @return enabled when Docker is available; disabled when it is not and CI
|
||||
** is not "true"
|
||||
** @throws IllegalStateException when Docker is unavailable and CI is "true"
|
||||
*******************************************************************************/
|
||||
static ConditionEvaluationResult evaluate(Boolean dockerAvailable, String ciValue)
|
||||
{
|
||||
if(Boolean.TRUE.equals(dockerAvailable))
|
||||
{
|
||||
return (ConditionEvaluationResult.enabled("Docker is available"));
|
||||
}
|
||||
|
||||
if("true".equalsIgnoreCase(ciValue))
|
||||
{
|
||||
throw (new IllegalStateException("Docker is required for integration tests when CI=true, but no usable Docker environment was found"));
|
||||
}
|
||||
|
||||
return (ConditionEvaluationResult.disabled("Docker is not available; skipping (with CI=true these tests fail instead)"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ConditionEvaluationResult;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tests for RequiresDockerCondition.
|
||||
*******************************************************************************/
|
||||
class RequiresDockerConditionTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Docker available: integration tests run, whether or not CI is set.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_dockerAvailable_enabled()
|
||||
{
|
||||
ConditionEvaluationResult local = RequiresDockerCondition.evaluate(true, null);
|
||||
ConditionEvaluationResult ci = RequiresDockerCondition.evaluate(true, "true");
|
||||
|
||||
assertThat(local.isDisabled()).isFalse();
|
||||
assertThat(ci.isDisabled()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker outside CI: integration tests are skipped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_noDockerOutsideCi_disabled()
|
||||
{
|
||||
assertThat(RequiresDockerCondition.evaluate(false, null).isDisabled()).isTrue();
|
||||
assertThat(RequiresDockerCondition.evaluate(false, "").isDisabled()).isTrue();
|
||||
assertThat(RequiresDockerCondition.evaluate(false, "false").isDisabled()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker with CI=true: evaluation throws, so the tests fail instead of
|
||||
** silently skipping.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEvaluate_noDockerInCi_throws()
|
||||
{
|
||||
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "true"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("CI=true");
|
||||
|
||||
assertThatThrownBy(() -> RequiresDockerCondition.evaluate(false, "TRUE"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetMetaDataProducer;
|
||||
import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchWidgetRenderer;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer output and config validation.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchQBitProducerTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testProducedMetaData() throws Exception
|
||||
{
|
||||
assertThat(qInstance.getQBits().values()).anySatisfy(q ->
|
||||
{
|
||||
assertThat(q.getGroupId()).isEqualTo("com.kingsrook.qbits");
|
||||
assertThat(q.getArtifactId()).isEqualTo("sql-workbench");
|
||||
});
|
||||
|
||||
for(String name : List.of("sqlWorkbenchNotebook", "sqlWorkbenchCell", "sqlWorkbenchNotebookVersion", "sqlWorkbenchResultSnapshot", "sqlWorkbenchExecution"))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
assertThat(table).as(name).isNotNull();
|
||||
assertThat(table.getBackendName()).isEqualTo(BACKEND_NAME);
|
||||
assertThat(table.getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
|
||||
assertThat(table.getPermissionRules().getPermissionBaseName()).as("tables need the admin permission, not plain access").isEqualTo("sqlWorkbenchAdmin");
|
||||
boolean writable = name.equals("sqlWorkbenchNotebook");
|
||||
assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT)).as(name).isEqualTo(writable);
|
||||
assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE)).as(name).isEqualTo(writable);
|
||||
assertThat(table.isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE)).as(name).isEqualTo(writable);
|
||||
assertThat(((RDBMSTableBackendDetails) table.getBackendDetails()).getTableName()).startsWith("sql_workbench_");
|
||||
assertThat(table.getSourceQBitName()).isNotBlank();
|
||||
}
|
||||
|
||||
QWidgetMetaData widget = (QWidgetMetaData) qInstance.getWidget(SqlWorkbenchWidgetMetaDataProducer.NAME);
|
||||
assertThat(widget.getType()).isEqualTo("SQL_WORKBENCH");
|
||||
|
||||
QAppMetaData app = qInstance.getApp(SqlWorkbenchAppMetaDataProducer.APP_NAME);
|
||||
assertThat(app.getWidgets()).containsExactly(SqlWorkbenchWidgetMetaDataProducer.NAME);
|
||||
assertThat(app.getChildren()).extracting(c -> c.getName()).containsExactly(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
assertThat(app.getPermissionRules().getPermissionBaseName()).isEqualTo("sqlWorkbench");
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// the qbit produces no processes (only QQQ's generic bulk ones //
|
||||
// are added to its tables by the enricher) //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
assertThat(qInstance.getProcesses() == null ? Map.of() : qInstance.getProcesses()).allSatisfy((name, process) -> assertThat(name).contains("."));
|
||||
|
||||
assertThat(QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences()).isNotEmpty();
|
||||
assertThat(SqlWorkbenchRuntime.get(qInstance)).isNotNull();
|
||||
assertThat(new SqlWorkbenchWidgetRenderer().render(null).getWidgetData().getType()).isEqualTo("SQL_WORKBENCH");
|
||||
assertThat(SqlWorkbenchQBitProducer.getVersion()).isNotBlank();
|
||||
assertThat(SqlWorkbenchQBitProducer.isJavalinAvailable()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNamespacePrefixAndReproduce() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(defineH2Backend("other"));
|
||||
SqlWorkbenchQBitConfig prefixed = new SqlWorkbenchQBitConfig().withBackendName("other").withRegisterRoutes(false);
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(prefixed).withNamespace("ns").produce(instance);
|
||||
|
||||
assertThat(instance.getTable("ns_sqlWorkbenchNotebook")).isNotNull();
|
||||
assertThat(instance.getTable("ns_sqlWorkbenchCell").getField("notebookId").getPossibleValueSourceName()).isEqualTo("ns_sqlWorkbenchNotebook");
|
||||
assertThat(instance.getApp("ns_sqlWorkbench")).isNotNull();
|
||||
assertThat(QJavalinMetaData.of(instance)).isNull();
|
||||
|
||||
SqlWorkbenchRuntime first = prefixed.getRuntime();
|
||||
new SqlWorkbenchQBitProducer().withQBitConfig(prefixed).getQBitMetaData();
|
||||
SqlWorkbenchQBitProducer producer = new SqlWorkbenchQBitProducer();
|
||||
producer.setQBitConfig(prefixed);
|
||||
producer.postProduceActions(null, instance);
|
||||
assertThat(prefixed.getRuntime()).isNotSameAs(first);
|
||||
assertThat(producer.getNamespace()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWithoutConfig() throws Exception
|
||||
{
|
||||
SqlWorkbenchQBitProducer producer = new SqlWorkbenchQBitProducer();
|
||||
assertThat(producer.isEnabled()).isFalse();
|
||||
assertThatThrownBy(() -> producer.produce(new QInstance())).isInstanceOf(QException.class).hasMessageContaining("no config");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testValidation()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(defineH2Backend("db"));
|
||||
instance.addBackend(new QBackendMetaData().withName("mem").withBackendType(MemoryBackendModule.class));
|
||||
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig(), instance)).anyMatch(e -> e.contains("backendName is required"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("nope"), instance)).anyMatch(e -> e.contains("Backend not found"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db"), instance)).isEmpty();
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withAllowedBackendNames(List.of("mem", "x")), instance))
|
||||
.anyMatch(e -> e.contains("not an RDBMS")).anyMatch(e -> e.contains("backend not found: x"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withReadOnlyCredentials("mem", new SqlWorkbenchCredentials()), instance))
|
||||
.anyMatch(e -> e.contains("non-RDBMS")).anyMatch(e -> e.contains("need a username"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withMaxRows(0).withWritePermissionName("").withDefaultPageSize(5000), instance))
|
||||
.anyMatch(e -> e.contains("maxRows")).anyMatch(e -> e.contains("writePermissionName")).anyMatch(e -> e.contains("defaultPageSize must not exceed"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withMaxAffectedRowsWithoutAcknowledgement(10).withMaxAffectedRows(5), instance))
|
||||
.anyMatch(e -> e.contains("maxAffectedRowsWithoutAcknowledgement must not exceed"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withAdminPermissionBaseName("sqlWorkbench"), instance))
|
||||
.anyMatch(e -> e.contains("adminPermissionBaseName must differ"));
|
||||
assertThat(errors(new SqlWorkbenchQBitConfig().withBackendName("db").withAdminPermissionBaseName(""), instance))
|
||||
.anyMatch(e -> e.contains("adminPermissionBaseName is required"));
|
||||
|
||||
QInstance bad = new QInstance();
|
||||
bad.addBackend(defineH2Backend("db"));
|
||||
assertThatThrownBy(() -> new SqlWorkbenchQBitProducer().withQBitConfig(new SqlWorkbenchQBitConfig().withBackendName("nope")).produce(bad))
|
||||
.isInstanceOf(QException.class).hasMessageContaining("Backend not found");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConfigDetails()
|
||||
{
|
||||
SqlWorkbenchQBitConfig config = new SqlWorkbenchQBitConfig().withBackendName("db")
|
||||
.withReadOnlyCredentials("db", new SqlWorkbenchCredentials().withUsername("ro").withPassword("secret"))
|
||||
.withWriteCredentials("db", new SqlWorkbenchCredentials().withUsername("rw").withPassword("secret2"));
|
||||
assertThat(config.toString()).doesNotContain("secret").doesNotContain("ro\"").contains("db");
|
||||
assertThat(config.getReadOnlyCredentials().get("db").toString()).doesNotContain("secret").doesNotContain("ro,");
|
||||
assertThat(config.getAccessPermissionName()).isEqualTo("sqlWorkbench.hasAccess");
|
||||
assertThat(config.getAdminPermissionName()).isEqualTo("sqlWorkbenchAdmin.hasAccess");
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// credentials never serialize - jdbcUrl can carry a password //
|
||||
////////////////////////////////////////////////////////////////
|
||||
String credentialsJson = com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(new SqlWorkbenchCredentials()
|
||||
.withUsername("ro").withPassword("secret").withJdbcUrl("jdbc:postgresql://db/app?password=urlsecret"));
|
||||
assertThat(credentialsJson).doesNotContain("secret").doesNotContain("urlsecret").doesNotContain("jdbc:");
|
||||
assertThat(config.getAdminPermissionRules().getPermissionBaseName()).isEqualTo("sqlWorkbenchAdmin");
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("db");
|
||||
assertThat(config.applyPrefix("t")).isEqualTo("t");
|
||||
assertThat(config.withTableNamePrefix("p").applyPrefix("t")).isEqualTo("p_t");
|
||||
|
||||
SqlWorkbenchQBitConfig empty = new SqlWorkbenchQBitConfig();
|
||||
empty.setReadOnlyCredentials(null);
|
||||
empty.setWriteCredentials(null);
|
||||
empty.withReadOnlyCredentials("a", new SqlWorkbenchCredentials()).withWriteCredentials("a", new SqlWorkbenchCredentials());
|
||||
assertThat(empty.getReadOnlyCredentials()).containsKey("a");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> errors(SqlWorkbenchQBitConfig config, QInstance instance)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(instance, errors);
|
||||
return (errors);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.Caller;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.CollectedResult;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ColumnInfo;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ResultCache;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SqlWorkbenchApi: permissions, capabilities, paging, history, export.
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchApiTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCallerPermissions() throws Exception
|
||||
{
|
||||
loginAs("nobody");
|
||||
assertThatThrownBy(() -> new SqlWorkbenchApi().caller("1.2.3.4")).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
loginAs(READER);
|
||||
Caller reader = new SqlWorkbenchApi().caller("1.2.3.4");
|
||||
assertThat(reader.userId()).isEqualTo(READER);
|
||||
assertThat(reader.canWrite()).isFalse();
|
||||
assertThat(reader.canShare()).isFalse();
|
||||
|
||||
loginAs(WRITER);
|
||||
Caller writer = new SqlWorkbenchApi().caller(null);
|
||||
assertThat(writer.canWrite()).isTrue();
|
||||
assertThat(writer.canShare()).isTrue();
|
||||
|
||||
config.setWriteModeEnabled(false);
|
||||
assertThat(new SqlWorkbenchApi().caller(null).canWrite()).isFalse();
|
||||
|
||||
QContext.init(qInstance, null);
|
||||
assertThatThrownBy(() -> new SqlWorkbenchApi().caller(null)).isInstanceOf(QAuthenticationException.class);
|
||||
|
||||
assertThatThrownBy(() -> com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime.get(new QInstance())).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testCapabilitiesAndSchema() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Map<String, Object> capabilities = api.capabilities(caller(READER));
|
||||
assertThat(capabilities.get("canWrite")).isEqualTo(false);
|
||||
List<Map<String, Object>> backends = (List<Map<String, Object>>) capabilities.get("backends");
|
||||
assertThat(backends).hasSize(1);
|
||||
assertThat(backends.get(0)).containsEntry("name", BACKEND_NAME).containsEntry("dialect", "h2").containsEntry("readOnlyEnforced", false);
|
||||
assertThat(capabilities.toString()).doesNotContain("username").doesNotContain("password").doesNotContain("jdbc:");
|
||||
assertThat((Map<String, Object>) capabilities.get("limits")).containsEntry("maxRows", 10_000);
|
||||
|
||||
Map<String, Object> schema = api.schema(caller(READER), BACKEND_NAME, false);
|
||||
assertThat(schema.get("dialect")).isEqualTo("h2");
|
||||
assertThat(schema.toString()).contains("sql_workbench_notebook").contains("first_name");
|
||||
List<Map<String, Object>> qqqTables = (List<Map<String, Object>>) schema.get("qqqTables");
|
||||
assertThat(qqqTables).anySatisfy(t ->
|
||||
{
|
||||
assertThat(t.get("name")).isEqualTo("person");
|
||||
assertThat(t.toString()).contains("sqlName=first_name");
|
||||
});
|
||||
assertThat(qqqTables).anySatisfy(t -> assertThat(t.get("sqlName")).isEqualTo("sql_workbench_execution"));
|
||||
assertThat((List<String>) schema.get("keywords")).contains("SELECT");
|
||||
assertThat((List<String>) schema.get("functions")).contains("count");
|
||||
|
||||
////////////////////////////////////////////
|
||||
// cached until refresh //
|
||||
////////////////////////////////////////////
|
||||
assertThat(api.schema(caller(READER), BACKEND_NAME, false)).isSameAs(schema);
|
||||
assertThat(api.schema(caller(READER), BACKEND_NAME, true)).isNotSameAs(schema);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testExecuteRowsHistoryExport() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Map<String, Object> response = api.execute(caller(READER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT id, first_name, salary FROM person WHERE salary > :s",
|
||||
"params", Map.of("s", new BigDecimal("1")), "pageSize", 2));
|
||||
String uuid = (String) response.get("executionUuid");
|
||||
assertThat((List<?>) response.get("rows")).hasSize(2);
|
||||
|
||||
Map<String, Object> page = api.rows(caller(READER), uuid, 0, 10, 2, "desc");
|
||||
List<List<Object>> rows = (List<List<Object>>) page.get("rows");
|
||||
assertThat(rows).hasSize(4);
|
||||
assertThat(rows.get(0).get(1)).isEqualTo("Tyler");
|
||||
assertThat(api.rows(caller(READER), uuid, 3, null, null, null).get("rows")).asList().hasSize(1);
|
||||
assertThatThrownBy(() -> api.rows(caller(OTHER_READER), uuid, 0, 10, null, null)).isInstanceOf(QNotFoundException.class);
|
||||
|
||||
api.explain(caller(READER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT 1"));
|
||||
assertThat(api.cancel(caller(READER), uuid).get("cancelled")).isEqualTo(false);
|
||||
|
||||
List<Map<String, Object>> history = (List<Map<String, Object>>) api.history(caller(READER), 10, null).get("executions");
|
||||
assertThat(history).hasSize(2);
|
||||
assertThat(history.get(0).get("mode")).isEqualTo("EXPLAIN");
|
||||
assertThat((List<?>) api.history(caller(READER), null, "other").get("executions")).isEmpty();
|
||||
assertThat((List<?>) api.history(caller(OTHER_READER), 10, null).get("executions")).isEmpty();
|
||||
|
||||
ResultCache.Entry entry = api.exportable(caller(READER), uuid);
|
||||
for(ResultExporter.Format format : ResultExporter.Format.values())
|
||||
{
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
ResultExporter.write(entry.result(), format, out);
|
||||
assertThat(out.size()).isGreaterThan(10);
|
||||
if(format == ResultExporter.Format.CSV)
|
||||
{
|
||||
assertThat(out.toString(StandardCharsets.UTF_8)).contains("id,first_name,salary").contains("Darin");
|
||||
}
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> api.execute(caller(READER), Map.of("sql", "SELECT 1"))).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> api.execute(caller(READER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT 1", "params", "x"))).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> api.execute(caller(READER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT 1", "pageSize", "x"))).isInstanceOf(QBadRequestException.class);
|
||||
////////////////////////////////////////////////////////////
|
||||
// ids are normalized at the edge: any case finds the run //
|
||||
////////////////////////////////////////////////////////////
|
||||
String upper = uuid.toUpperCase();
|
||||
assertThat(api.rows(caller(READER), upper, 0, 10, null, null).get("executionUuid")).isEqualTo(uuid);
|
||||
assertThat(api.exportable(caller(READER), " " + upper + " ")).isNotNull();
|
||||
assertThat(api.cancel(caller(READER), upper).get("cancelled")).isEqualTo(false);
|
||||
assertThat(SqlWorkbenchApi.normalizeUuid(null)).isNull();
|
||||
|
||||
String clientUuid = "9B2C7A10-5E1F-4C3D-8A2B-1C0D9E8F7A6B";
|
||||
Map<String, Object> mine = api.execute(caller(READER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT 1", "executionUuid", clientUuid));
|
||||
assertThat(mine.get("executionUuid")).isEqualTo(clientUuid.toLowerCase());
|
||||
assertThat(api.rows(caller(READER), clientUuid, 0, 10, null, null).get("rowCount")).isEqualTo(1);
|
||||
assertThatThrownBy(() -> api.execute(caller(READER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT 1", "executionUuid", clientUuid.toLowerCase())))
|
||||
.isInstanceOf(com.kingsrook.qbits.sqlworkbench.engine.DuplicateExecutionException.class);
|
||||
|
||||
assertThat(SqlWorkbenchApi.intOrNull("12")).isEqualTo(12);
|
||||
assertThat(SqlWorkbenchApi.intOrNull("")).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSnapshotThroughApi() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi();
|
||||
Integer notebook = (Integer) api.notebooks().create(Map.of("title", "N"), caller(READER)).get("id");
|
||||
Map<String, Object> response = api.execute(caller(READER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT 1 AS one"));
|
||||
Map<String, Object> snapshot = api.createSnapshot(caller(READER), notebook, Map.of("executionUuid", response.get("executionUuid")));
|
||||
assertThat(snapshot.get("rowCount")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExporterDetails() throws Exception
|
||||
{
|
||||
assertThat(ResultExporter.csvLine(java.util.Arrays.asList("a,b", null, 1, true, "say \"hi\"", List.of(1, 2))))
|
||||
.isEqualTo("\"a,b\",,1,true,\"say \"\"hi\"\"\",\"[1,2]\"\r\n");
|
||||
///////////////////////////////////////////////////////////////
|
||||
// text that a spreadsheet would run as a formula is quoted; //
|
||||
// numbers (even negative) are not //
|
||||
///////////////////////////////////////////////////////////////
|
||||
assertThat(ResultExporter.csvLine(java.util.Arrays.asList("=HYPERLINK(\"x\")", "+1", "-2", "@SUM(A1)", "\tx", "\rx", -3, "a=b", "")))
|
||||
.isEqualTo("\"'=HYPERLINK(\"\"x\"\")\",'+1,'-2,'@SUM(A1),'\tx,\"'\rx\",-3,a=b,\r\n");
|
||||
assertThat(ResultExporter.csvLine(List.of("=1+1", "ok"))).startsWith("'=1+1,ok");
|
||||
assertThat(ResultExporter.uniqueLabels(List.of(new ColumnInfo("a", "a", "int", 4, "number"), new ColumnInfo("a", "a", "int", 4, "number"), new ColumnInfo("b", null, "int", 4, "number"))))
|
||||
.containsExactly("a", "a_2", "b");
|
||||
assertThatThrownBy(() -> ResultExporter.Format.of("pdf")).isInstanceOf(QBadRequestException.class);
|
||||
assertThat(ResultExporter.Format.of("XLSX").getExtension()).isEqualTo("xlsx");
|
||||
|
||||
CollectedResult result = new CollectedResult(List.of(new ColumnInfo("j", "j", "json", 1111, "json"), new ColumnInfo("n", "n", "int", 4, "number")),
|
||||
List.of(java.util.Arrays.asList(Map.of("$binary", true), 5), java.util.Arrays.asList(null, null)), false, null, 0);
|
||||
ByteArrayOutputStream json = new ByteArrayOutputStream();
|
||||
ResultExporter.write(result, ResultExporter.Format.JSON, json);
|
||||
assertThat(json.toString(StandardCharsets.UTF_8)).contains("\"n\":5");
|
||||
ByteArrayOutputStream xlsx = new ByteArrayOutputStream();
|
||||
ResultExporter.write(result, ResultExporter.Format.XLSX, xlsx);
|
||||
assertThat(xlsx.size()).isGreaterThan(100);
|
||||
}
|
||||
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.api;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The routes over real HTTP (Javalin on a random port, MOCK authentication
|
||||
** with the sessionId cookie choosing the test user).
|
||||
*******************************************************************************/
|
||||
class SqlWorkbenchRouteProviderTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private Javalin javalin;
|
||||
private String base;
|
||||
private HttpClient client = HttpClient.newHttpClient();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void startServer()
|
||||
{
|
||||
QContext.clear();
|
||||
SqlWorkbenchRouteProvider provider = new SqlWorkbenchRouteProvider();
|
||||
provider.setQInstance(qInstance);
|
||||
javalin = Javalin.create(provider::acceptJavalinConfig).start(0);
|
||||
base = "http://localhost:" + javalin.port() + SqlWorkbenchRouteProvider.BASE_PATH;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stopServer()
|
||||
{
|
||||
javalin.stop();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> send(String method, String path, String user, String body, String contentType) throws Exception
|
||||
{
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + path)).header("Cookie", "sessionId=" + user);
|
||||
if(contentType != null)
|
||||
{
|
||||
builder.header("Content-Type", contentType);
|
||||
}
|
||||
builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
|
||||
return (client.send(builder.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> json(HttpResponse<String> response) throws Exception
|
||||
{
|
||||
return (JsonUtils.toObject(response.body(), new TypeReference<Map<String, Object>>() {}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRegistration()
|
||||
{
|
||||
com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchRoutes.register(qInstance);
|
||||
List<QCodeReference> references = QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences();
|
||||
assertThat(references).filteredOn(r -> r.getName().equals(SqlWorkbenchRouteProvider.class.getName())).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testEndToEnd() throws Exception
|
||||
{
|
||||
assertThat(send("GET", "/capabilities", "nobody", null, null).statusCode()).isEqualTo(403);
|
||||
assertThat(send("GET", "/capabilities", READER, null, null).statusCode()).isEqualTo(200);
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// JSON only - a form post (CSRF shape) is refused //
|
||||
////////////////////////////////////////////////////////////
|
||||
assertThat(send("POST", "/execute", READER, "backendName=h2&sql=SELECT+1", "application/x-www-form-urlencoded").statusCode()).isEqualTo(400);
|
||||
assertThat(send("POST", "/execute", READER, "not json", "application/json").statusCode()).isEqualTo(400);
|
||||
|
||||
HttpResponse<String> run = send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT id, salary FROM person WHERE salary > :s ORDER BY id\",\"params\":{\"s\":25000.25}}", "application/json");
|
||||
assertThat(run.statusCode()).isEqualTo(200);
|
||||
Map<String, Object> result = json(run);
|
||||
assertThat(result.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(result.get("rowCount")).isEqualTo(2);
|
||||
String uuid = (String) result.get("executionUuid");
|
||||
|
||||
assertThat(json(send("GET", "/executions/" + uuid + "/rows?offset=1&limit=1&sort=0&dir=desc", READER, null, null)).get("rows")).asList().hasSize(1);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/rows?offset=x", READER, null, null).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/rows", OTHER_READER, null, null).statusCode()).isEqualTo(404);
|
||||
|
||||
HttpResponse<String> csv = send("GET", "/executions/" + uuid + "/export?format=csv", READER, null, null);
|
||||
assertThat(csv.statusCode()).isEqualTo(200);
|
||||
assertThat(csv.headers().firstValue("Content-Disposition").orElse("")).contains("attachment").contains(".csv");
|
||||
assertThat(csv.body()).contains("id,salary");
|
||||
assertThat(send("GET", "/executions/" + uuid + "/export?format=pdf", READER, null, null).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/executions/" + uuid + "/export", OTHER_READER, null, null).statusCode()).isEqualTo(404);
|
||||
|
||||
assertThat(send("POST", "/execute", READER, "{\"backendName\":\"h2\",\"sql\":\"DELETE FROM person\",\"mode\":\"WRITE\"}", "application/json").statusCode()).isEqualTo(403);
|
||||
assertThat(send("POST", "/execute", READER, "{\"backendName\":\"nope\",\"sql\":\"SELECT 1\"}", "application/json").statusCode()).isEqualTo(404);
|
||||
assertThat(json(send("POST", "/explain", READER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1\"}", "application/json")).get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(json(send("POST", "/executions/" + uuid + "/cancel", READER, "{}", "application/json")).get("cancelled")).isEqualTo(false);
|
||||
assertThat((List<?>) json(send("GET", "/history?limit=5", READER, null, null)).get("executions")).hasSize(3);
|
||||
assertThat(json(send("GET", "/schema/h2", READER, null, null)).get("dialect")).isEqualTo("h2");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a client-chosen id: any case on the paths, single-use (409) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
String clientUuid = "3F2504E0-4F89-11D3-9A0C-0305E82C3301";
|
||||
String body = "{\"backendName\":\"h2\",\"sql\":\"SELECT 1 AS one\",\"executionUuid\":\"" + clientUuid + "\"}";
|
||||
assertThat(json(send("POST", "/execute", READER, body, "application/json")).get("executionUuid")).isEqualTo(clientUuid.toLowerCase());
|
||||
assertThat(send("GET", "/executions/" + clientUuid + "/rows", READER, null, null).statusCode()).isEqualTo(200);
|
||||
assertThat(send("GET", "/executions/" + clientUuid + "/export", READER, null, null).statusCode()).isEqualTo(200);
|
||||
HttpResponse<String> again = send("POST", "/execute", READER, body, "application/json");
|
||||
assertThat(again.statusCode()).isEqualTo(409);
|
||||
assertThat(again.body()).contains("already used");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotebookRoutes() throws Exception
|
||||
{
|
||||
Map<String, Object> created = json(send("POST", "/notebooks", WRITER, "{\"title\":\"N1\",\"cells\":[{\"cellKey\":\"a\",\"cellType\":\"SQL\",\"content\":\"SELECT 1\"}]}", "application/json"));
|
||||
Integer id = (Integer) created.get("id");
|
||||
|
||||
assertThat((List<?>) json(send("GET", "/notebooks?scope=mine", WRITER, null, null)).get("notebooks")).hasSize(1);
|
||||
assertThat(json(send("GET", "/notebooks/" + id, WRITER, null, null)).get("title")).isEqualTo("N1");
|
||||
assertThat(send("GET", "/notebooks/" + id, READER, null, null).statusCode()).isEqualTo(404);
|
||||
assertThat(send("GET", "/notebooks/abc", WRITER, null, null).statusCode()).isEqualTo(400);
|
||||
|
||||
assertThat(json(send("PUT", "/notebooks/" + id, WRITER, "{\"title\":\"N2\"}", "application/json")).get("currentVersion")).isEqualTo(2);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/publish", WRITER, "{\"shared\":true}", "application/json")).get("visibility")).isEqualTo("SHARED");
|
||||
assertThat((List<?>) json(send("GET", "/notebooks?scope=shared", READER, null, null)).get("notebooks")).hasSize(1);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/duplicate", READER, "{}", "application/json")).get("ownerUserId")).isEqualTo(READER);
|
||||
assertThat((List<?>) json(send("GET", "/notebooks/" + id + "/versions", WRITER, null, null)).get("versions")).hasSize(2);
|
||||
assertThat(json(send("GET", "/notebooks/" + id + "/versions/1", WRITER, null, null)).get("versionNumber")).isEqualTo(1);
|
||||
assertThat(json(send("POST", "/notebooks/" + id + "/versions/1/restore", WRITER, "{}", "application/json")).get("title")).isEqualTo("N1");
|
||||
|
||||
String uuid = (String) json(send("POST", "/execute", WRITER, "{\"backendName\":\"h2\",\"sql\":\"SELECT 1 AS one\"}", "application/json")).get("executionUuid");
|
||||
Map<String, Object> snapshot = json(send("POST", "/notebooks/" + id + "/snapshots", WRITER, "{\"executionUuid\":\"" + uuid + "\",\"cellKey\":\"a\"}", "application/json"));
|
||||
assertThat((List<?>) json(send("GET", "/notebooks/" + id + "/snapshots", WRITER, null, null)).get("snapshots")).hasSize(1);
|
||||
assertThat(json(send("DELETE", "/notebooks/" + id + "/snapshots/" + snapshot.get("id"), WRITER, null, null)).get("deleted")).isEqualTo(true);
|
||||
|
||||
assertThat(send("DELETE", "/notebooks/" + id, READER, null, null).statusCode()).isEqualTo(403);
|
||||
assertThat(json(send("DELETE", "/notebooks/" + id, WRITER, null, null)).get("deleted")).isEqualTo(true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.sql.Types;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchCredentials;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for the engine's smaller parts: value conversion, the result
|
||||
** cache, write tokens, running statements, session guard (SQLite), and
|
||||
** target resolution.
|
||||
*******************************************************************************/
|
||||
class EngineComponentsTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A clock tests can move.
|
||||
*******************************************************************************/
|
||||
static class MutableClock extends Clock
|
||||
{
|
||||
Instant now = Instant.parse("2026-10-11T00:00:00Z");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public java.time.ZoneId getZone()
|
||||
{
|
||||
return (ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Clock withZone(java.time.ZoneId zone)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Instant instant()
|
||||
{
|
||||
return (now);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testValueConversion() throws Exception
|
||||
{
|
||||
ResultCollector collector = new ResultCollector(10, 1000, 5);
|
||||
assertThat(collector.convert(null)).isNull();
|
||||
assertThat(collector.convert(5)).isEqualTo(5);
|
||||
assertThat(collector.convert(5L)).isEqualTo(5L);
|
||||
assertThat(collector.convert(Long.MAX_VALUE)).isEqualTo(String.valueOf(Long.MAX_VALUE));
|
||||
assertThat(collector.convert(new BigInteger("123456789012345678901234"))).isEqualTo("123456789012345678901234");
|
||||
assertThat(collector.convert(BigInteger.TEN)).isEqualTo(10L);
|
||||
assertThat(collector.convert(new BigDecimal("1.25"))).isEqualTo(new BigDecimal("1.25"));
|
||||
assertThat(collector.convert(new BigDecimal("12345678901234567.5"))).isEqualTo("12345678901234567.5");
|
||||
assertThat(collector.convert(Double.NaN)).isEqualTo("NaN");
|
||||
assertThat(collector.convert(1.5d)).isEqualTo(1.5d);
|
||||
assertThat(collector.convert(1.5f)).isEqualTo(1.5d);
|
||||
assertThat(collector.convert(Float.POSITIVE_INFINITY)).isEqualTo("Infinity");
|
||||
assertThat(collector.convert(new java.util.concurrent.atomic.AtomicInteger(3))).isEqualTo("3");
|
||||
assertThat(collector.convert("abcdefgh")).isEqualTo("abcde…");
|
||||
assertThat(collector.convert(new byte[] { 1, 2 })).isInstanceOf(Map.class);
|
||||
assertThat(collector.convert(Timestamp.valueOf("2026-01-01 10:00:00"))).isEqualTo("2026-01-01T10:00");
|
||||
assertThat(collector.convert(java.sql.Date.valueOf("2026-01-01"))).isEqualTo("2026-01-01");
|
||||
assertThat(collector.convert(java.sql.Time.valueOf("10:11:12"))).isEqualTo("10:11:12");
|
||||
assertThat(collector.convert(LocalDate.of(2026, 1, 2))).isEqualTo("2026-01-02");
|
||||
assertThat(collector.convert(true)).isEqualTo(true);
|
||||
|
||||
assertThat(ResultCollector.kindOf(Types.OTHER, "jsonb")).isEqualTo("json");
|
||||
assertThat(ResultCollector.kindOf(Types.BIT, "bit")).isEqualTo("boolean");
|
||||
assertThat(ResultCollector.kindOf(Types.BIT, "bit varying")).isEqualTo("string");
|
||||
assertThat(ResultCollector.kindOf(Types.ARRAY, "_int4")).isEqualTo("array");
|
||||
assertThat(ResultCollector.kindOf(Types.TIME, "time")).isEqualTo("time");
|
||||
assertThat(ResultCollector.kindOf(Types.OTHER, null)).isEqualTo("other");
|
||||
assertThat(ResultCollector.isZoned("timestamptz")).isTrue();
|
||||
assertThat(ResultCollector.isZoned("TIMESTAMP WITH TIME ZONE")).isTrue();
|
||||
assertThat(ResultCollector.isZoned("timestamp")).isFalse();
|
||||
|
||||
assertThat(ResultCollector.estimateBytes(null)).isEqualTo(4);
|
||||
assertThat(ResultCollector.estimateBytes(List.of("a", 1))).isGreaterThan(16);
|
||||
assertThat(ResultCollector.estimateBytes(Map.of())).isEqualTo(128);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testResultCache()
|
||||
{
|
||||
MutableClock clock = new MutableClock();
|
||||
ResultCache cache = new ResultCache(2, 60, clock);
|
||||
CollectedResult result = new CollectedResult(List.of(new ColumnInfo("n", "n", "int", Types.INTEGER, "number")),
|
||||
new ArrayList<>(List.of(Arrays.asList((Object) 3), Arrays.asList((Object) null), Arrays.asList((Object) 1), Arrays.asList((Object) "2"))), false, null, 0);
|
||||
|
||||
cache.put("a", "u1", "db", result);
|
||||
cache.put("b", "u1", "db", result);
|
||||
cache.get("a", "u1");
|
||||
cache.put("c", "u2", "db", result);
|
||||
assertThat(cache.keys()).containsExactly("a", "c");
|
||||
assertThat(cache.get("a", "u2")).isEmpty();
|
||||
assertThat(cache.get("missing", "u1")).isEmpty();
|
||||
|
||||
clock.now = clock.now.plusSeconds(61);
|
||||
assertThat(cache.size()).isZero();
|
||||
|
||||
assertThat(ResultCache.page(result, 0, 10, 0, false)).extracting(r -> r.get(0)).containsExactly(1, "2", 3, null);
|
||||
assertThat(ResultCache.page(result, 0, 10, 0, true)).extracting(r -> r.get(0)).containsExactly(3, "2", 1, null);
|
||||
assertThat(ResultCache.page(result, 1, 2, null, false)).hasSize(2);
|
||||
assertThat(ResultCache.page(result, 10, 2, 99, false)).isEmpty();
|
||||
assertThat(ResultCache.compareValues(true, false)).isPositive();
|
||||
assertThat(ResultCache.compareValues("b", "a")).isPositive();
|
||||
assertThat(ResultCache.compareValues("x1", "2")).isPositive();
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWritePreviewTokens()
|
||||
{
|
||||
MutableClock clock = new MutableClock();
|
||||
WritePreviewTokens tokens = new WritePreviewTokens(60, clock);
|
||||
String token = tokens.issue("u", "db", "hash", 3L, false);
|
||||
|
||||
assertThat(tokens.find(token, "other", "db", "hash")).isNull();
|
||||
assertThat(tokens.find(token, "u", "other", "hash")).isNull();
|
||||
assertThat(tokens.find(token, "u", "db", "other")).isNull();
|
||||
assertThat(tokens.find(null, "u", "db", "hash")).isNull();
|
||||
assertThat(tokens.find(token, "u", "db", "hash").affectedRows()).isEqualTo(3L);
|
||||
assertThat(tokens.expiresAt(token)).isAfter(clock.now);
|
||||
assertThat(tokens.expiresAt("nope")).isNull();
|
||||
assertThat(tokens.consume(token)).isTrue();
|
||||
assertThat(tokens.consume(token)).isFalse();
|
||||
assertThat(tokens.consume(null)).isFalse();
|
||||
|
||||
String expiring = tokens.issue("u", "db", "hash", null, false);
|
||||
clock.now = clock.now.plusSeconds(61);
|
||||
assertThat(tokens.find(expiring, "u", "db", "hash")).isNull();
|
||||
assertThat(tokens.size()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunningStatements() throws Exception
|
||||
{
|
||||
RunningStatements running = new RunningStatements();
|
||||
RunningStatements.Running one = running.register("1", "u", 1);
|
||||
assertThat(running.register("2", "u", 1)).isNull();
|
||||
assertThat(running.size()).isEqualTo(1);
|
||||
|
||||
try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:running"); Statement statement = connection.createStatement())
|
||||
{
|
||||
one.setStatement(statement);
|
||||
assertThat(running.cancel("1", "u")).isTrue();
|
||||
assertThat(one.isCancelled()).isTrue();
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// a statement set after the cancel is cancelled at once //
|
||||
////////////////////////////////////////////////////////////
|
||||
one.setStatement(statement);
|
||||
}
|
||||
one.setStatement(null);
|
||||
assertThat(running.cancel("missing", "u")).isFalse();
|
||||
running.unregister("1", one);
|
||||
assertThat(running.size()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A re-sent id is refused, never overwrites the original, and the
|
||||
** original's registration is only removed by its own unregister.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunningStatementsRefuseDuplicateIds() throws Exception
|
||||
{
|
||||
RunningStatements running = new RunningStatements();
|
||||
RunningStatements.Running original = running.register("dup", "u", 5);
|
||||
assertThat(running.isRunning("dup")).isTrue();
|
||||
|
||||
assertThatThrownBy(() -> running.register("dup", "u", 5)).isInstanceOf(DuplicateExecutionException.class);
|
||||
assertThatThrownBy(() -> running.register("dup", "someone-else", 5)).isInstanceOf(DuplicateExecutionException.class);
|
||||
assertThat(running.size()).isEqualTo(1);
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a stranger's unregister under the same id leaves the original //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
RunningStatements.Running other = running.register("other", "u", 5);
|
||||
running.unregister("dup", other);
|
||||
assertThat(running.isRunning("dup")).isTrue();
|
||||
assertThat(running.cancel("dup", "u")).isTrue();
|
||||
assertThat(original.isCancelled()).isTrue();
|
||||
|
||||
running.unregister("dup", original);
|
||||
running.unregister("other", other);
|
||||
assertThat(running.size()).isZero();
|
||||
assertThat(running.isRunning("dup")).isFalse();
|
||||
assertThat(running.isRunning(null)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQLite: query_only blocks writes in read-only sessions, and is reset so
|
||||
** the same connection writes again afterwards.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSessionGuardSqlite(@TempDir Path dir) throws Exception
|
||||
{
|
||||
String url = "jdbc:sqlite:" + dir.resolve("test.db");
|
||||
try(Connection setup = DriverManager.getConnection(url); Statement statement = setup.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE t (id INTEGER)");
|
||||
}
|
||||
|
||||
Connection connection = DriverManager.getConnection(url);
|
||||
try(SessionGuard guard = new SessionGuard(new NonClosingConnection(connection).proxy(), SqlDialect.SQLITE, true, 5).begin(); Statement statement = guard.getConnection().createStatement())
|
||||
{
|
||||
assertThatThrownBy(() -> statement.execute("INSERT INTO t VALUES (1)")).isInstanceOf(SQLException.class);
|
||||
}
|
||||
|
||||
assertThat(connection.getAutoCommit()).isTrue();
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO t VALUES (1)");
|
||||
}
|
||||
|
||||
try(SessionGuard guard = new SessionGuard(new NonClosingConnection(connection).proxy(), SqlDialect.SQLITE, false, 5).begin(); Statement statement = guard.getConnection().createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO t VALUES (2)");
|
||||
guard.commit();
|
||||
}
|
||||
try(Statement statement = connection.createStatement(); var rs = statement.executeQuery("SELECT count(*) FROM t"))
|
||||
{
|
||||
rs.next();
|
||||
assertThat(rs.getInt(1)).isEqualTo(2);
|
||||
}
|
||||
connection.close();
|
||||
assertThat(Files.exists(dir.resolve("test.db"))).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A guard over a closed connection cannot restore it, and aborts it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSessionGuardAbortsWhenRestoreFails() throws Exception
|
||||
{
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:abort");
|
||||
SessionGuard guard = new SessionGuard(connection, SqlDialect.H2, true, 5).begin();
|
||||
connection.close();
|
||||
guard.close();
|
||||
assertThat(connection.isClosed()).isTrue();
|
||||
|
||||
Connection other = DriverManager.getConnection("jdbc:h2:mem:abort2");
|
||||
SessionGuard plain = new SessionGuard(other, SqlDialect.H2, false, 5);
|
||||
plain.close();
|
||||
assertThat(other.isClosed()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBackendsAndDerivedCredentials() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
RDBMSBackendMetaData db = new RDBMSBackendMetaData().withName("db").withVendor("h2").withHostName("mem").withDatabaseName("x");
|
||||
RDBMSBackendMetaData replica = new RDBMSBackendMetaData().withName("replica").withVendor("h2").withHostName("mem").withDatabaseName("y");
|
||||
db.setReadOnlyBackendMetaData(replica);
|
||||
instance.addBackend(db);
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName("skip").withVendor("h2").withHostName("mem").withDatabaseName("z"));
|
||||
|
||||
SqlWorkbenchQBitConfig config = new SqlWorkbenchQBitConfig().withBackendName("db").withExcludedBackendNames(List.of("skip"));
|
||||
SqlWorkbenchRuntime runtime = new SqlWorkbenchRuntime(config);
|
||||
WorkbenchBackends backends = new WorkbenchBackends(instance, runtime);
|
||||
|
||||
assertThat(backends.listTargets()).extracting(WorkbenchBackends.Target::name).containsExactly("db");
|
||||
assertThatThrownBy(() -> backends.getTarget("skip")).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> backends.getTarget(null)).isInstanceOf(QNotFoundException.class);
|
||||
|
||||
WorkbenchBackends.Target target = backends.getTarget("db");
|
||||
assertThat(backends.connectionSource(target, true)).isSameAs(replica);
|
||||
assertThat(backends.connectionSource(target, false)).isSameAs(db);
|
||||
assertThat(backends.hasReadOnlyCredentials(target)).isFalse();
|
||||
|
||||
config.withReadOnlyCredentials("db", new SqlWorkbenchCredentials().withUsername("${env.NO_SUCH_VAR_SQLWB}").withPassword("pw"))
|
||||
.withWriteCredentials("db", new SqlWorkbenchCredentials().withUsername("writer").withJdbcUrl("jdbc:h2:mem:custom"));
|
||||
RDBMSBackendMetaData readOnly = backends.connectionSource(target, true);
|
||||
assertThat(readOnly.getName()).isEqualTo("db" + WorkbenchBackends.READ_ONLY_SUFFIX);
|
||||
assertThat(readOnly.getPassword()).isEqualTo("pw");
|
||||
assertThat(readOnly.getJdbcUrl()).contains("jdbc:h2:mem:x");
|
||||
assertThat(backends.connectionSource(target, true)).isSameAs(readOnly);
|
||||
assertThat(backends.hasReadOnlyCredentials(target)).isTrue();
|
||||
assertThat(backends.connectionSource(target, false).getJdbcUrl()).isEqualTo("jdbc:h2:mem:custom");
|
||||
|
||||
config.setAllowedBackendNames(List.of("nothing"));
|
||||
assertThat(backends.listTargets()).isEmpty();
|
||||
assertThatThrownBy(() -> backends.getTarget("db" + WorkbenchBackends.READ_ONLY_SUFFIX)).isInstanceOf(QNotFoundException.class);
|
||||
|
||||
config.setUseBackendReadOnlyReplica(false);
|
||||
config.setReadOnlyCredentials(Map.of());
|
||||
assertThat(backends.connectionSource(target, true)).isSameAs(db);
|
||||
runtime.close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wraps a connection so close() does not close it (to inspect it after a
|
||||
** guard released it).
|
||||
*******************************************************************************/
|
||||
static class NonClosingConnection
|
||||
{
|
||||
private final Connection delegate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
NonClosingConnection(Connection delegate)
|
||||
{
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Connection proxy()
|
||||
{
|
||||
return ((Connection) java.lang.reflect.Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[] { Connection.class }, (p, method, args) ->
|
||||
{
|
||||
if("close".equals(method.getName()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (method.invoke(delegate, args));
|
||||
}
|
||||
catch(java.lang.reflect.InvocationTargetException e)
|
||||
{
|
||||
throw (e.getCause());
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.engine;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Engine behaviour against H2: read-only runs, gating, write preview and
|
||||
** commit, drift, guards, explain, and the audit trail.
|
||||
*******************************************************************************/
|
||||
class SqlExecutionEngineTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private SqlExecutionEngine engine;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
engine = new SqlExecutionEngine(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> run(String sql, Map<String, Object> params, String user) throws Exception
|
||||
{
|
||||
return (engine.execute(ExecutionRequest.readOnly(BACKEND_NAME, sql, params), caller(user)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<SqlWorkbenchExecution> audits() throws Exception
|
||||
{
|
||||
return (QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, SqlWorkbenchExecution.class, new QQueryFilter()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testReadOnlySelectWithTypesAndParams() throws Exception
|
||||
{
|
||||
Map<String, Object> response = run("SELECT id, first_name, birth_date, salary, active, photo, create_date FROM person WHERE id <= :maxId ORDER BY id",
|
||||
Map.of("maxId", 3), READER);
|
||||
|
||||
assertThat(response.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(response.get("rowCount")).isEqualTo(3);
|
||||
List<Map<String, Object>> columns = (List<Map<String, Object>>) response.get("columns");
|
||||
assertThat(columns).extracting(c -> c.get("kind")).containsExactly("number", "string", "date", "number", "boolean", "binary", "datetime");
|
||||
|
||||
List<List<Object>> rows = (List<List<Object>>) response.get("rows");
|
||||
assertThat(rows.get(0).get(1)).isEqualTo("Darin");
|
||||
assertThat(rows.get(0).get(2)).isEqualTo("1980-05-31");
|
||||
assertThat((BigDecimal) rows.get(0).get(3)).isEqualByComparingTo("25000.00");
|
||||
assertThat(rows.get(0).get(4)).isEqualTo(true);
|
||||
assertThat((Map<String, Object>) rows.get(0).get(5)).containsEntry("$binary", true).containsEntry("hexPreview", "cafebabe");
|
||||
assertThat(rows.get(0).get(6)).isEqualTo("2026-01-01T10:00");
|
||||
assertThat(rows.get(2).get(3)).isNull();
|
||||
|
||||
List<SqlWorkbenchExecution> audits = audits();
|
||||
assertThat(audits).hasSize(1);
|
||||
SqlWorkbenchExecution audit = audits.get(0);
|
||||
assertThat(audit.getStatus()).isEqualTo("SUCCEEDED");
|
||||
assertThat(audit.getMode()).isEqualTo("READ_ONLY");
|
||||
assertThat(audit.getUserId()).isEqualTo(READER);
|
||||
assertThat(audit.getSqlText()).contains(":maxId");
|
||||
assertThat(audit.getParametersJson()).contains("maxId");
|
||||
assertThat(audit.getRowsReturned()).isEqualTo(3);
|
||||
assertThat(audit.getDurationMs()).isNotNull();
|
||||
assertThat(audit.getExecutionUuid()).isEqualTo(response.get("executionUuid"));
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// the result is cached for its owner only //
|
||||
//////////////////////////////////////////////
|
||||
SqlWorkbenchRuntime runtime = SqlWorkbenchRuntime.get(qInstance);
|
||||
assertThat(runtime.getResultCache().get((String) response.get("executionUuid"), READER)).isPresent();
|
||||
assertThat(runtime.getResultCache().get((String) response.get("executionUuid"), OTHER_READER)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReadOnlyRefusals() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> run("DELETE FROM person", Map.of(), READER)).isInstanceOf(QBadRequestException.class).hasMessageContaining("write mode");
|
||||
assertThatThrownBy(() -> run("SELECT 1; SELECT 2", Map.of(), READER)).isInstanceOf(QBadRequestException.class).hasMessageContaining("2 statements");
|
||||
assertThatThrownBy(() -> run("SET search_path = x", Map.of(), WRITER)).isInstanceOf(QBadRequestException.class).hasMessageContaining("Not allowed");
|
||||
assertThatThrownBy(() -> run("SELECT * FROM person WHERE id = :id", Map.of(), READER)).isInstanceOf(QBadRequestException.class).hasMessageContaining(":id");
|
||||
assertThatThrownBy(() -> run(" ", Map.of(), READER)).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> run("-- nothing", Map.of(), READER)).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> engine.execute(ExecutionRequest.readOnly("nope", "SELECT 1", Map.of()), caller(READER))).isInstanceOf(QNotFoundException.class);
|
||||
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getStatus).containsOnly("REJECTED").hasSize(4);
|
||||
assertThat(QueryAction.execute("person", new QQueryFilter())).hasSize(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSqlErrorIsAResult() throws Exception
|
||||
{
|
||||
Map<String, Object> response = run("SELECT no_such_column FROM person", Map.of(), READER);
|
||||
assertThat(response.get("status")).isEqualTo("FAILED");
|
||||
assertThat((String) response.get("error")).isNotBlank();
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getStatus).containsExactly("FAILED");
|
||||
assertThat(audits().get(0).getErrorMessage()).isNotBlank();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWriteNeedsPermission() throws Exception
|
||||
{
|
||||
ExecutionRequest request = new ExecutionRequest(BACKEND_NAME, "DELETE FROM person", Map.of(), true, null, null, null, null, false);
|
||||
assertThatThrownBy(() -> engine.execute(request, caller(READER))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getStatus).containsExactly("REJECTED");
|
||||
assertThat(QueryAction.execute("person", new QQueryFilter())).hasSize(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWritePreviewThenCommit() throws Exception
|
||||
{
|
||||
String sql = "UPDATE person SET active = FALSE WHERE salary < :limit; UPDATE person SET last_name = 'X' WHERE id = 1";
|
||||
ExecutionRequest preview = new ExecutionRequest(BACKEND_NAME, sql, Map.of("limit", 26000), true, null, null, null, null, false);
|
||||
|
||||
Map<String, Object> previewResponse = engine.execute(preview, caller(WRITER));
|
||||
assertThat(previewResponse.get("status")).isEqualTo("PREVIEW");
|
||||
assertThat(previewResponse.get("dryRunSupported")).isEqualTo(true);
|
||||
assertThat(previewResponse.get("totalAffectedRows")).isEqualTo(3L);
|
||||
assertThat(previewResponse.get("rowGuardExceeded")).isEqualTo(false);
|
||||
String token = (String) previewResponse.get("confirmToken");
|
||||
assertThat(token).isNotBlank();
|
||||
|
||||
///////////////////////////////////////
|
||||
// the dry run was rolled back //
|
||||
///////////////////////////////////////
|
||||
assertThat(QueryAction.execute("person", new QQueryFilter(new QFilterCriteria("lastName", QCriteriaOperator.EQUALS, "X")))).isEmpty();
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// a token is bound to its user, SQL and params - and single use //
|
||||
////////////////////////////////////////////////////////////////
|
||||
ExecutionRequest otherSql = new ExecutionRequest(BACKEND_NAME, sql + " ", Map.of("limit", 26000), true, null, null, null, token, false);
|
||||
assertThatThrownBy(() -> engine.execute(otherSql, caller(WRITER))).isInstanceOf(QBadRequestException.class).hasMessageContaining("preview it again");
|
||||
|
||||
ExecutionRequest commit = new ExecutionRequest(BACKEND_NAME, sql, Map.of("limit", 26000), true, null, null, null, token, false);
|
||||
Map<String, Object> commitResponse = engine.execute(commit, caller(WRITER));
|
||||
assertThat(commitResponse.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(commitResponse.get("rowsAffected")).isEqualTo(3L);
|
||||
assertThat(QueryAction.execute("person", new QQueryFilter(new QFilterCriteria("lastName", QCriteriaOperator.EQUALS, "X")))).hasSize(1);
|
||||
|
||||
assertThatThrownBy(() -> engine.execute(commit, caller(WRITER))).isInstanceOf(QBadRequestException.class);
|
||||
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getMode).contains("WRITE_PREVIEW", "WRITE_COMMIT");
|
||||
assertThat(audits()).filteredOn(a -> "WRITE_COMMIT".equals(a.getMode()) && "SUCCEEDED".equals(a.getStatus())).hasSize(1)
|
||||
.first().extracting(SqlWorkbenchExecution::getRowsAffected).isEqualTo(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWriteDrift() throws Exception
|
||||
{
|
||||
String sql = "DELETE FROM person WHERE active = TRUE";
|
||||
Map<String, Object> preview = engine.execute(new ExecutionRequest(BACKEND_NAME, sql, Map.of(), true, null, null, null, null, false), caller(WRITER));
|
||||
assertThat(preview.get("totalAffectedRows")).isEqualTo(3L);
|
||||
|
||||
run("SELECT 1", Map.of(), READER);
|
||||
QueryAction.execute("person", new QQueryFilter());
|
||||
try(var connection = engine.getBackends().openConnection(engine.getBackends().getTarget(BACKEND_NAME), false); var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE person SET active = TRUE WHERE id = 3");
|
||||
}
|
||||
|
||||
Map<String, Object> commit = engine.execute(new ExecutionRequest(BACKEND_NAME, sql, Map.of(), true, null, null, null, (String) preview.get("confirmToken"), false), caller(WRITER));
|
||||
assertThat(commit.get("status")).isEqualTo("DRIFTED");
|
||||
assertThat(commit.get("actualAffectedRows")).isEqualTo(4L);
|
||||
assertThat(QueryAction.execute("person", new QQueryFilter())).hasSize(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRowGuardAndHardCap() throws Exception
|
||||
{
|
||||
config.setMaxAffectedRowsWithoutAcknowledgement(2);
|
||||
config.setMaxAffectedRows(4);
|
||||
|
||||
String sql = "UPDATE person SET first_name = upper(first_name) WHERE id <= 3";
|
||||
Map<String, Object> preview = engine.execute(new ExecutionRequest(BACKEND_NAME, sql, Map.of(), true, null, null, null, null, false), caller(WRITER));
|
||||
assertThat(preview.get("rowGuardExceeded")).isEqualTo(true);
|
||||
String token = (String) preview.get("confirmToken");
|
||||
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, sql, Map.of(), true, null, null, null, token, false), caller(WRITER)))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("row guard");
|
||||
|
||||
Map<String, Object> commit = engine.execute(new ExecutionRequest(BACKEND_NAME, sql, Map.of(), true, null, null, null, token, true), caller(WRITER));
|
||||
assertThat(commit.get("status")).isEqualTo("SUCCEEDED");
|
||||
|
||||
String all = "UPDATE person SET first_name = lower(first_name)";
|
||||
Map<String, Object> preview2 = engine.execute(new ExecutionRequest(BACKEND_NAME, all, Map.of(), true, null, null, null, null, false), caller(WRITER));
|
||||
Map<String, Object> commit2 = engine.execute(new ExecutionRequest(BACKEND_NAME, all, Map.of(), true, null, null, null, (String) preview2.get("confirmToken"), true), caller(WRITER));
|
||||
assertThat(commit2.get("status")).isEqualTo("REJECTED");
|
||||
assertThat(QueryAction.execute("person", new QQueryFilter(new QFilterCriteria("firstName", QCriteriaOperator.EQUALS, "darin")))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDdlOnNonTransactionalDialectSkipsDryRun() throws Exception
|
||||
{
|
||||
Map<String, Object> preview = engine.execute(new ExecutionRequest(BACKEND_NAME, "CREATE TABLE scratch (id INT)", Map.of(), true, null, null, null, null, false), caller(WRITER));
|
||||
assertThat(preview.get("dryRunSupported")).isEqualTo(false);
|
||||
assertThat(preview.get("totalAffectedRows")).isNull();
|
||||
|
||||
Map<String, Object> commit = engine.execute(new ExecutionRequest(BACKEND_NAME, "CREATE TABLE scratch (id INT)", Map.of(), true, null, null, null, (String) preview.get("confirmToken"), false), caller(WRITER));
|
||||
assertThat(commit.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(run("SELECT count(*) FROM scratch", Map.of(), READER).get("status")).isEqualTo("SUCCEEDED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTruncationByRowsAndBytes() throws Exception
|
||||
{
|
||||
config.setMaxRows(2);
|
||||
Map<String, Object> rows = run("SELECT * FROM person", Map.of(), READER);
|
||||
assertThat(rows.get("truncated")).isEqualTo(true);
|
||||
assertThat(rows.get("truncatedReason")).isEqualTo("ROWS");
|
||||
assertThat(rows.get("rowCount")).isEqualTo(2);
|
||||
|
||||
config.setMaxRows(100);
|
||||
config.setMaxResultBytes(200L);
|
||||
Map<String, Object> bytes = run("SELECT * FROM person", Map.of(), READER);
|
||||
assertThat(bytes.get("truncatedReason")).isEqualTo("BYTES");
|
||||
assertThat(audits()).filteredOn(a -> Boolean.TRUE.equals(a.getTruncated())).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPageSize() throws Exception
|
||||
{
|
||||
Map<String, Object> response = engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT * FROM person", Map.of(), false, 7, "cell-1", 2, null, false), caller(READER));
|
||||
assertThat((List<?>) response.get("rows")).hasSize(2);
|
||||
assertThat(response.get("hasMore")).isEqualTo(true);
|
||||
assertThat(audits().get(0).getNotebookId()).isEqualTo(7);
|
||||
assertThat(audits().get(0).getCellKey()).isEqualTo("cell-1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExplain() throws Exception
|
||||
{
|
||||
Map<String, Object> response = engine.explain(BACKEND_NAME, "SELECT * FROM person WHERE id = :id", Map.of("id", 1), false, caller(READER));
|
||||
assertThat(response.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(response.get("plan")).isInstanceOf(Map.class);
|
||||
assertThat((String) response.get("raw")).containsIgnoringCase("person");
|
||||
|
||||
assertThatThrownBy(() -> engine.explain(BACKEND_NAME, "SELECT 1", Map.of(), true, caller(READER))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> engine.explain(BACKEND_NAME, "EXPLAIN SELECT 1", Map.of(), false, caller(READER))).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> engine.explain(BACKEND_NAME, "DELETE FROM person", Map.of(), false, caller(WRITER))).isInstanceOf(QBadRequestException.class);
|
||||
|
||||
Map<String, Object> analyzed = engine.explain(BACKEND_NAME, "DELETE FROM person WHERE id = 1", Map.of(), true, caller(WRITER));
|
||||
assertThat(analyzed.get("status")).isEqualTo("SUCCEEDED");
|
||||
assertThat(QueryAction.execute("person", new QQueryFilter())).hasSize(5);
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getMode).contains("EXPLAIN", "EXPLAIN_ANALYZE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConcurrencyLimitAndCancel() throws Exception
|
||||
{
|
||||
SqlWorkbenchRuntime runtime = SqlWorkbenchRuntime.get(qInstance);
|
||||
config.setMaxConcurrentExecutionsPerUser(1);
|
||||
RunningStatements.Running blocker = runtime.getRunningStatements().register("blocker", READER, 1);
|
||||
assertThat(blocker).isNotNull();
|
||||
|
||||
assertThatThrownBy(() -> run("SELECT 1", Map.of(), READER)).isInstanceOf(QBadRequestException.class).hasMessageContaining("running");
|
||||
assertThat(run("SELECT 1", Map.of(), OTHER_READER).get("status")).isEqualTo("SUCCEEDED");
|
||||
|
||||
assertThat(engine.cancel("blocker", caller(OTHER_READER))).isFalse();
|
||||
assertThat(engine.cancel("blocker", caller(READER))).isTrue();
|
||||
assertThat(blocker.isCancelled()).isTrue();
|
||||
runtime.getRunningStatements().unregister("blocker", blocker);
|
||||
assertThat(run("SELECT 1", Map.of(), READER).get("status")).isEqualTo("SUCCEEDED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSqlLengthAndStatementLimits() throws Exception
|
||||
{
|
||||
config.setMaxSqlLength(10);
|
||||
assertThatThrownBy(() -> run("SELECT 1 + 1 + 1", Map.of(), READER)).isInstanceOf(QBadRequestException.class).hasMessageContaining("longer");
|
||||
|
||||
config.setMaxSqlLength(1000);
|
||||
config.setMaxStatementsPerRun(1);
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 1; SELECT 2", Map.of(), true, null, null, null, null, false), caller(WRITER)))
|
||||
.isInstanceOf(QBadRequestException.class).hasMessageContaining("Too many");
|
||||
|
||||
config.setWriteModeEnabled(false);
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, "DELETE FROM person", Map.of(), true, null, null, null, null, false), caller(WRITER)))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client-chosen execution id is used (so the client can cancel early),
|
||||
** and must be a UUID.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testClientExecutionUuid() throws Exception
|
||||
{
|
||||
String uuid = "0F8FAD5B-D9CB-469F-A165-70867728950E";
|
||||
Map<String, Object> response = engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 1", Map.of(), false, null, null, null, null, false, uuid), caller(READER));
|
||||
assertThat(response.get("executionUuid")).isEqualTo(uuid.toLowerCase());
|
||||
assertThat(audits().get(0).getExecutionUuid()).isEqualTo(uuid.toLowerCase());
|
||||
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 1", Map.of(), false, null, null, null, null, false, "not-a-uuid"), caller(READER)))
|
||||
.isInstanceOf(QBadRequestException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client uuid already in the audit is refused (in any case), before
|
||||
** anything is registered or recorded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReusedExecutionUuidIsRefused() throws Exception
|
||||
{
|
||||
String uuid = "1b4e28ba-2fa1-11d2-883f-0016d3cca427";
|
||||
engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 1", Map.of(), false, null, null, null, null, false, uuid), caller(READER));
|
||||
assertThat(audits()).hasSize(1);
|
||||
|
||||
for(String again : List.of(uuid, uuid.toUpperCase()))
|
||||
{
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 2", Map.of(), false, null, null, null, null, false, again), caller(READER)))
|
||||
.isInstanceOf(DuplicateExecutionException.class).hasMessageContaining("already used");
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 2", Map.of(), false, null, null, null, null, false, again), caller(OTHER_READER)))
|
||||
.isInstanceOf(DuplicateExecutionException.class);
|
||||
}
|
||||
assertThat(audits()).hasSize(1);
|
||||
assertThat(SqlWorkbenchRuntime.get(qInstance).getRunningStatements().size()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-sending the id of a run in flight neither overwrites nor removes its
|
||||
** registration, so the concurrency limit and cancel still hold.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testInFlightExecutionUuidIsRefused() throws Exception
|
||||
{
|
||||
SqlWorkbenchRuntime runtime = SqlWorkbenchRuntime.get(qInstance);
|
||||
String uuid = "6fa459ea-ee8a-3ca4-894e-db77e160355e";
|
||||
config.setMaxConcurrentExecutionsPerUser(1);
|
||||
RunningStatements.Running inFlight = runtime.getRunningStatements().register(uuid, READER, 1);
|
||||
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 1", Map.of(), false, null, null, null, null, false, uuid.toUpperCase()), caller(READER)))
|
||||
.isInstanceOf(DuplicateExecutionException.class);
|
||||
assertThatThrownBy(() -> engine.execute(new ExecutionRequest(BACKEND_NAME, "SELECT 1", Map.of(), false, null, null, null, null, false, uuid), caller(OTHER_READER)))
|
||||
.isInstanceOf(DuplicateExecutionException.class);
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// the original is still registered and counted //
|
||||
///////////////////////////////////////////////////
|
||||
assertThat(runtime.getRunningStatements().isRunning(uuid)).isTrue();
|
||||
assertThatThrownBy(() -> run("SELECT 1", Map.of(), READER)).isInstanceOf(QBadRequestException.class).hasMessageContaining("running");
|
||||
assertThat(engine.cancel(uuid.toUpperCase(), caller(READER))).isTrue();
|
||||
assertThat(inFlight.isCancelled()).isTrue();
|
||||
assertThat(audits()).extracting(SqlWorkbenchExecution::getExecutionUuid).doesNotContain(uuid);
|
||||
|
||||
runtime.getRunningStatements().unregister(uuid, inFlight);
|
||||
assertThat(run("SELECT 1", Map.of(), READER).get("status")).isEqualTo("SUCCEEDED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An audit insert that fails (here: a duplicate executionUuid) throws,
|
||||
** rather than being dropped silently.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAuditRecordFailsLoudly() throws Exception
|
||||
{
|
||||
String uuid = (String) run("SELECT 1", Map.of(), READER).get("executionUuid");
|
||||
ExecutionAuditor auditor = new ExecutionAuditor(config);
|
||||
SqlWorkbenchExecution rejected = new SqlWorkbenchExecution()
|
||||
.withExecutionUuid(uuid)
|
||||
.withUserId(READER)
|
||||
.withBackendName(BACKEND_NAME)
|
||||
.withMode(ExecutionMode.READ_ONLY.name())
|
||||
.withStatus(ExecutionStatus.REJECTED.name())
|
||||
.withSqlText("SELECT 1")
|
||||
.withStartTime(java.time.Instant.now());
|
||||
assertThatThrownBy(() -> auditor.record(rejected)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QException.class).hasMessageContaining("audit");
|
||||
assertThat(audits()).hasSize(1);
|
||||
assertThat(auditor.exists(uuid)).isTrue();
|
||||
assertThat(auditor.exists("00000000-0000-0000-0000-000000000000")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A connection that cannot be opened answers with a generic message: the
|
||||
** driver's (URL, host, user) stays in the server log.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConnectionFailureMessageIsGeneric() throws Exception
|
||||
{
|
||||
config.withReadOnlyCredentials(BACKEND_NAME, new com.kingsrook.qbits.sqlworkbench.SqlWorkbenchCredentials()
|
||||
.withUsername("secret_user").withPassword("secret_pw").withJdbcUrl("jdbc:h2:mem:no_such_secret_db;IFEXISTS=TRUE"));
|
||||
|
||||
Map<String, Object> response = run("SELECT 1", Map.of(), READER);
|
||||
assertThat(response.get("status")).isEqualTo("FAILED");
|
||||
assertThat(response.get("error")).isEqualTo("Could not connect to backend h2; see the server log.");
|
||||
assertThat(response.get("sqlState")).isNull();
|
||||
|
||||
SqlWorkbenchExecution audit = audits().get(0);
|
||||
assertThat(audit.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(audit.getErrorMessage()).doesNotContain("secret").doesNotContain("no_such");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unexpected RuntimeException inside a started run finishes its audit
|
||||
** row as FAILED (not left RUNNING) and still unregisters it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnexpectedExceptionFinishesAuditAsFailed() throws Exception
|
||||
{
|
||||
config.setDefaultPageSize(null);
|
||||
assertThatThrownBy(() -> run("SELECT 1", Map.of(), READER)).isInstanceOf(NullPointerException.class);
|
||||
|
||||
SqlWorkbenchExecution audit = audits().get(0);
|
||||
assertThat(audit.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(audit.getErrorMessage()).contains("unexpectedly");
|
||||
assertThat(audit.getEndTime()).isNotNull();
|
||||
assertThat(SqlWorkbenchRuntime.get(qInstance).getRunningStatements().size()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSqlHashBindsParams()
|
||||
{
|
||||
assertThat(SqlExecutionEngine.sqlHash("a", "SELECT :x", Map.of("x", 1))).isNotEqualTo(SqlExecutionEngine.sqlHash("a", "SELECT :x", Map.of("x", 2)));
|
||||
assertThat(SqlExecutionEngine.sqlHash("a", "SELECT 1", null)).isEqualTo(SqlExecutionEngine.sqlHash("a", "SELECT 1", Map.of()));
|
||||
assertThat(SqlExecutionEngine.sqlHash("a", "SELECT 1", Map.of())).isNotEqualTo(SqlExecutionEngine.sqlHash("b", "SELECT 1", Map.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAuditCannotBeInsertedByUsers() throws Exception
|
||||
{
|
||||
run("SELECT 1", Map.of(), READER);
|
||||
List<QRecord> records = QueryAction.execute(SqlWorkbenchExecution.TABLE_NAME, new QQueryFilter());
|
||||
assertThat(records).hasSize(1);
|
||||
assertThat(qInstance.getTable(SqlWorkbenchExecution.TABLE_NAME).isCapabilityEnabled(qInstance.getBackend(BACKEND_NAME),
|
||||
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT)).isFalse();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.explain;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plan parsing per dialect, from fixture output.
|
||||
*******************************************************************************/
|
||||
class PlanParserTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExplainSql()
|
||||
{
|
||||
assertThat(PlanParser.explainSql(SqlDialect.POSTGRES, "SELECT 1", false)).isEqualTo("EXPLAIN (FORMAT JSON) SELECT 1");
|
||||
assertThat(PlanParser.explainSql(SqlDialect.POSTGRES, "SELECT 1", true)).isEqualTo("EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) SELECT 1");
|
||||
assertThat(PlanParser.explainSql(SqlDialect.MYSQL, "SELECT 1", false)).isEqualTo("EXPLAIN FORMAT=JSON SELECT 1");
|
||||
assertThat(PlanParser.explainSql(SqlDialect.MYSQL, "SELECT 1", true)).isEqualTo("EXPLAIN ANALYZE SELECT 1");
|
||||
assertThat(PlanParser.explainSql(SqlDialect.SQLITE, "SELECT 1", true)).isEqualTo("EXPLAIN QUERY PLAN SELECT 1");
|
||||
assertThat(PlanParser.explainSql(SqlDialect.H2, "SELECT 1", true)).isEqualTo("EXPLAIN ANALYZE SELECT 1");
|
||||
assertThat(PlanParser.supportsAnalyze(SqlDialect.SQLITE)).isFalse();
|
||||
assertThat(PlanParser.supportsAnalyze(SqlDialect.POSTGRES)).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testPostgres()
|
||||
{
|
||||
String json = """
|
||||
[{"Plan": {"Node Type": "Hash Join", "Join Type": "Inner", "Startup Cost": 1.5, "Total Cost": 10.25, "Plan Rows": 100, "Plan Width": 8,
|
||||
"Actual Total Time": 0.5, "Actual Rows": 90, "Actual Loops": 1, "Hash Cond": "(a.id = b.a_id)",
|
||||
"Plans": [{"Node Type": "Seq Scan", "Relation Name": "a", "Alias": "a", "Total Cost": 4.0, "Plan Rows": 100},
|
||||
{"Node Type": "Index Scan", "Relation Name": "b", "Alias": "bb", "Index Name": "b_pk", "Total Cost": 2.0, "Plan Rows": 9}]},
|
||||
"Planning Time": 0.1, "Execution Time": 0.7}]""";
|
||||
PlanNode root = PlanParser.parse(SqlDialect.POSTGRES, List.of(List.of(json)), true);
|
||||
assertThat(root.getLabel()).isEqualTo("Inner Hash Join");
|
||||
assertThat(root.getTotalCost()).isEqualTo(10.25);
|
||||
assertThat(root.getActualRows()).isEqualTo(90.0);
|
||||
assertThat(root.getLoops()).isEqualTo(1.0);
|
||||
assertThat(root.getExtra()).containsKeys("Hash Cond", "Planning Time", "Execution Time", "Plan Width");
|
||||
assertThat(root.getChildren()).hasSize(2);
|
||||
assertThat(root.getChildren().get(0).getDetail()).isEqualTo("on a");
|
||||
assertThat(root.getChildren().get(1).getDetail()).isEqualTo("on b bb using b_pk");
|
||||
|
||||
Map<String, Object> map = root.toMap();
|
||||
assertThat((List<Map<String, Object>>) map.get("children")).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMySql()
|
||||
{
|
||||
String json = """
|
||||
{"query_block": {"select_id": 1, "cost_info": {"query_cost": "12.5"},
|
||||
"nested_loop": [{"table": {"table_name": "a", "access_type": "ALL", "rows_examined_per_scan": 10, "cost_info": {"prefix_cost": "2.0"}, "attached_condition": "x"}},
|
||||
{"table": {"table_name": "b", "access_type": "ref", "key": "PRIMARY"}}],
|
||||
"ordering_operation": {"using_filesort": true, "table": {"table_name": "c", "access_type": "index"}}}}""";
|
||||
PlanNode root = PlanParser.parse(SqlDialect.MYSQL, List.of(List.of(json)), false);
|
||||
assertThat(root.getTotalCost()).isEqualTo(12.5);
|
||||
assertThat(root.getChildren()).extracting(PlanNode::getLabel).contains("ALL", "ref", "ordering_operation");
|
||||
assertThat(root.getChildren().get(0).getPlanRows()).isEqualTo(10.0);
|
||||
assertThat(root.getChildren().get(0).getExtra()).containsKey("attached_condition");
|
||||
|
||||
PlanNode analyze = PlanParser.parse(SqlDialect.MYSQL, List.of(List.of("-> Limit: 10 row(s)\n -> Table scan on t (cost=1.0 rows=5)\n")), true);
|
||||
assertThat(analyze.getChildren()).hasSize(1);
|
||||
assertThat(analyze.getChildren().get(0).getLabel()).isEqualTo("Limit: 10 row(s)");
|
||||
assertThat(analyze.getChildren().get(0).getChildren().get(0).getLabel()).startsWith("Table scan on t");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSqliteTextAndFallbacks()
|
||||
{
|
||||
PlanNode sqlite = PlanParser.parse(SqlDialect.SQLITE, List.of(List.of(2, 0, 0, "SCAN a"), List.of(5, 2, 0, "SEARCH b USING INDEX"), List.of(9, 0, 0, "USE TEMP B-TREE")), false);
|
||||
assertThat(sqlite.getChildren()).extracting(PlanNode::getLabel).containsExactly("SCAN a", "USE TEMP B-TREE");
|
||||
assertThat(sqlite.getChildren().get(0).getChildren().get(0).getLabel()).isEqualTo("SEARCH b USING INDEX");
|
||||
|
||||
PlanNode h2 = PlanParser.parse(SqlDialect.H2, List.of(List.of("SELECT\n 1\nFROM t")), false);
|
||||
assertThat(h2.getChildren()).hasSize(2);
|
||||
|
||||
assertThat(PlanParser.parse(SqlDialect.POSTGRES, List.of(), false).getLabel()).isEqualTo("(empty plan)");
|
||||
assertThat(PlanParser.parse(SqlDialect.POSTGRES, List.of(List.of("not json")), false).getChildren()).hasSize(1);
|
||||
assertThat(PlanParser.raw(List.of(List.of(1, "a"), List.of(2, "b")))).isEqualTo("1 | a\n2 | b");
|
||||
assertThat(PlanParser.parseDouble("x")).isNull();
|
||||
assertThat(PlanParser.parseDouble(null)).isNull();
|
||||
assertThat(PlanParser.parseDouble("2.5")).isEqualTo(2.5);
|
||||
|
||||
PlanNode node = new PlanNode();
|
||||
node.setLabel("l");
|
||||
node.setDetail("d");
|
||||
node.setStartupCost(1.0);
|
||||
node.setTotalCost(2.0);
|
||||
node.setPlanRows(3.0);
|
||||
node.setActualTimeMs(4.0);
|
||||
node.setActualRows(5.0);
|
||||
node.setLoops(6.0);
|
||||
assertThat(node.toMap()).containsEntry("label", "l").containsEntry("detail", "d").containsEntry("startupCost", 1.0).containsEntry("actualTimeMs", 4.0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.api.SqlWorkbenchApi;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchExecution;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebook;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchResultSnapshot;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.TableQueryExecutor;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryInput;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.io.TableQueryOutputInterface;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's tables are not reachable through the generic QQQ table API by
|
||||
** ordinary workbench users (only by holders of the admin permission), while
|
||||
** the workbench's own endpoints keep working for them.
|
||||
*******************************************************************************/
|
||||
class TableAccessTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private static final List<String> TABLES = List.of(SqlWorkbenchNotebook.TABLE_NAME, SqlWorkbenchCell.TABLE_NAME, SqlWorkbenchNotebookVersion.TABLE_NAME,
|
||||
SqlWorkbenchResultSnapshot.TABLE_NAME, SqlWorkbenchExecution.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query a table the way /qqq/v1/table/{table}/query does.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> tableApiQuery(String tableName) throws Exception
|
||||
{
|
||||
List<QRecord> records = new ArrayList<>();
|
||||
TableQueryInput input = new TableQueryInput();
|
||||
input.setTableName(tableName);
|
||||
new TableQueryExecutor().execute(input, (TableQueryOutputInterface) records::addAll);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A writer's personal notebook (with a cell, a version and a pinned
|
||||
** snapshot) and an audited execution.
|
||||
*******************************************************************************/
|
||||
private Integer seedWriterData(SqlWorkbenchApi api) throws Exception
|
||||
{
|
||||
loginAs(WRITER);
|
||||
Map<String, Object> notebook = api.notebooks().create(Map.of("title", "Private payroll", "cells",
|
||||
List.of(Map.of("cellKey", "a", "cellType", "SQL", "content", "SELECT salary FROM person"))), caller(WRITER));
|
||||
Integer id = (Integer) notebook.get("id");
|
||||
|
||||
Map<String, Object> response = api.execute(caller(WRITER), Map.of("backendName", BACKEND_NAME, "sql", "SELECT first_name, salary FROM person ORDER BY id"));
|
||||
assertThat(response.get("status")).isEqualTo("SUCCEEDED");
|
||||
api.createSnapshot(caller(WRITER), id, Map.of("executionUuid", response.get("executionUuid"), "cellKey", "a"));
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Another ordinary user cannot read the writer's personal notebook, cells,
|
||||
** versions, snapshot or audit row through the generic table API, nor
|
||||
** write any of those tables; the owner cannot either (no generic access
|
||||
** at all). The workbench API still hides the notebook from the other user.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOrdinaryUsersHaveNoGenericTableAccess() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Integer id = seedWriterData(api);
|
||||
|
||||
for(String user : List.of(OTHER_READER, WRITER))
|
||||
{
|
||||
loginAs(user);
|
||||
for(String table : TABLES)
|
||||
{
|
||||
assertThatThrownBy(() -> tableApiQuery(table)).as(user + " querying " + table).isInstanceOf(QPermissionDeniedException.class);
|
||||
for(TablePermissionSubType subType : List.of(TablePermissionSubType.READ, TablePermissionSubType.INSERT, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(table), table, subType)).as(user + " " + subType + " " + table).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// through the workbench API the notebook does not exist for the //
|
||||
// other reader, and their history holds none of the writer's //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
loginAs(OTHER_READER);
|
||||
assertThatThrownBy(() -> api.notebooks().get(id, caller(OTHER_READER))).isInstanceOf(QNotFoundException.class);
|
||||
assertThatThrownBy(() -> api.notebooks().snapshots(id, caller(OTHER_READER))).isInstanceOf(QNotFoundException.class);
|
||||
assertThat(api.notebooks().list("mine", caller(OTHER_READER))).isEmpty();
|
||||
assertThat(api.notebooks().list("shared", caller(OTHER_READER))).isEmpty();
|
||||
assertThat((List<?>) api.history(caller(OTHER_READER), 50, null).get("executions")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The workbench's own endpoints work for an ordinary user (session without
|
||||
** the admin permission): notebooks, versions, snapshots, history.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWorkbenchApiStillWorksForOrdinaryUsers() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Integer id = seedWriterData(api);
|
||||
|
||||
loginAs(WRITER);
|
||||
assertThat(api.notebooks().get(id, caller(WRITER)).get("title")).isEqualTo("Private payroll");
|
||||
assertThat(api.notebooks().update(id, Map.of("title", "Payroll"), caller(WRITER)).get("currentVersion")).isEqualTo(2);
|
||||
assertThat(api.notebooks().versions(id, caller(WRITER))).hasSize(2);
|
||||
assertThat(api.notebooks().snapshots(id, caller(WRITER))).hasSize(1);
|
||||
assertThat(api.notebooks().list("mine", caller(WRITER))).hasSize(1);
|
||||
assertThat((List<?>) api.history(caller(WRITER), 50, null).get("executions")).hasSize(1);
|
||||
|
||||
api.notebooks().publish(id, true, caller(WRITER));
|
||||
loginAs(READER);
|
||||
assertThat(api.notebooks().get(id, caller(READER)).get("canEdit")).isEqualTo(false);
|
||||
assertThat(api.notebooks().duplicate(id, caller(READER)).get("ownerUserId")).isEqualTo(READER);
|
||||
|
||||
loginAs(WRITER);
|
||||
api.notebooks().delete(id, caller(WRITER));
|
||||
assertThat(api.notebooks().list("mine", caller(WRITER))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A holder of the admin permission can read everything through the
|
||||
** generic table API.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAdminHasGenericTableAccess() throws Exception
|
||||
{
|
||||
SqlWorkbenchApi api = new SqlWorkbenchApi(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
seedWriterData(api);
|
||||
|
||||
loginAs(ADMIN);
|
||||
assertThat(tableApiQuery(SqlWorkbenchNotebook.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchCell.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchNotebookVersion.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchResultSnapshot.TABLE_NAME)).hasSize(1);
|
||||
assertThat(tableApiQuery(SqlWorkbenchExecution.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.notebook;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig;
|
||||
import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.ExecutionRequest;
|
||||
import com.kingsrook.qbits.sqlworkbench.engine.SqlExecutionEngine;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchCell;
|
||||
import com.kingsrook.qbits.sqlworkbench.model.SqlWorkbenchNotebookVersion;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notebook CRUD, visibility, versions and snapshots.
|
||||
*******************************************************************************/
|
||||
class NotebookServiceTest extends BaseSqlWorkbenchTest
|
||||
{
|
||||
private NotebookService service;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(SqlWorkbenchQBitConfig config)
|
||||
{
|
||||
config.setNotebookVersionRetention(3);
|
||||
config.setSnapshotMaxRows(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
service = new NotebookService(SqlWorkbenchRuntime.get(qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> body(String title, List<Map<String, Object>> cells)
|
||||
{
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("title", title);
|
||||
body.put("defaultBackendName", BACKEND_NAME);
|
||||
body.put("parameters", List.of(Map.of("name", "since", "type", "DATE", "defaultValue", "2026-01-01")));
|
||||
body.put("cells", cells);
|
||||
return (body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> cell(String key, String type, String content)
|
||||
{
|
||||
Map<String, Object> cell = new LinkedHashMap<>();
|
||||
cell.put("cellKey", key);
|
||||
cell.put("cellType", type);
|
||||
cell.put("content", content);
|
||||
return (cell);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testCreateGetUpdateVersions() throws Exception
|
||||
{
|
||||
Map<String, Object> created = service.create(body("People", List.of(cell("a", "MARKDOWN", "# Hi"), cell("b", "sql", "SELECT 1"))), caller(READER));
|
||||
Integer id = (Integer) created.get("id");
|
||||
assertThat(created.get("visibility")).isEqualTo("PERSONAL");
|
||||
assertThat(created.get("currentVersion")).isEqualTo(1);
|
||||
assertThat(created.get("canEdit")).isEqualTo(true);
|
||||
assertThat((List<Map<String, Object>>) created.get("cells")).extracting(c -> c.get("cellType")).containsExactly("MARKDOWN", "SQL");
|
||||
assertThat((List<Object>) created.get("parameters")).hasSize(1);
|
||||
|
||||
Map<String, Object> update = body("People v2", List.of(cell("b", "SQL", "SELECT 2")));
|
||||
update.put("comment", "trim");
|
||||
Map<String, Object> updated = service.update(id, update, caller(READER));
|
||||
assertThat(updated.get("title")).isEqualTo("People v2");
|
||||
assertThat(updated.get("currentVersion")).isEqualTo(2);
|
||||
assertThat((List<?>) updated.get("cells")).hasSize(1);
|
||||
assertThat(QueryAction.execute(SqlWorkbenchCell.TABLE_NAME, new QQueryFilter())).hasSize(1);
|
||||
|
||||
List<Map<String, Object>> versions = service.versions(id, caller(READER));
|
||||
assertThat(versions).extracting(v -> v.get("versionNumber")).containsExactly(2, 1);
|
||||
assertThat(versions.get(0).get("comment")).isEqualTo("trim");
|
||||
|
||||
Map<String, Object> v1 = service.version(id, 1, caller(READER));
|
||||
assertThat(((Map<String, Object>) v1.get("notebook")).get("title")).isEqualTo("People");
|
||||
|
||||
Map<String, Object> restored = service.restore(id, 1, caller(READER));
|
||||
assertThat(restored.get("title")).isEqualTo("People");
|
||||
assertThat(restored.get("currentVersion")).isEqualTo(3);
|
||||
assertThat((List<?>) restored.get("cells")).hasSize(2);
|
||||
|
||||
////////////////////////////////////
|
||||
// retention keeps the newest 3 //
|
||||
////////////////////////////////////
|
||||
service.update(id, Map.of("title", "People v4"), caller(READER));
|
||||
assertThat(QueryAction.execute(SqlWorkbenchNotebookVersion.TABLE_NAME, new QQueryFilter())).hasSize(3);
|
||||
assertThat(service.get(id, caller(READER)).get("cells")).asList().hasSize(2);
|
||||
|
||||
assertThatThrownBy(() -> service.version(id, 1, caller(READER))).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testVisibilityAndSharing() throws Exception
|
||||
{
|
||||
Integer mine = (Integer) service.create(body("Mine", List.of()), caller(WRITER)).get("id");
|
||||
|
||||
assertThat(service.list("mine", caller(WRITER))).hasSize(1);
|
||||
assertThat(service.list("mine", caller(READER))).isEmpty();
|
||||
assertThat(service.list("shared", caller(READER))).isEmpty();
|
||||
assertThatThrownBy(() -> service.get(mine, caller(READER))).isInstanceOf(QNotFoundException.class);
|
||||
|
||||
assertThatThrownBy(() -> service.publish(mine, true, caller(READER))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(service.publish(mine, true, caller(WRITER)).get("visibility")).isEqualTo("SHARED");
|
||||
|
||||
assertThat(service.list("shared", caller(READER))).hasSize(1);
|
||||
assertThat(service.get(mine, caller(READER)).get("canEdit")).isEqualTo(false);
|
||||
assertThatThrownBy(() -> service.update(mine, Map.of("title", "x"), caller(READER))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> service.delete(mine, caller(READER))).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
Map<String, Object> copy = service.duplicate(mine, caller(READER));
|
||||
assertThat(copy.get("title")).isEqualTo("Copy of Mine");
|
||||
assertThat(copy.get("ownerUserId")).isEqualTo(READER);
|
||||
assertThat(copy.get("visibility")).isEqualTo("PERSONAL");
|
||||
|
||||
assertThat(service.publish(mine, false, caller(WRITER)).get("visibility")).isEqualTo("PERSONAL");
|
||||
assertThat(service.list("shared", caller(READER))).isEmpty();
|
||||
|
||||
service.delete(mine, caller(WRITER));
|
||||
assertThatThrownBy(() -> service.get(mine, caller(WRITER))).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testValidation() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> service.create(body(" ", List.of()), caller(READER))).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> service.create(body("x", List.of(cell("a", "PYTHON", "x"))), caller(READER))).isInstanceOf(QBadRequestException.class);
|
||||
Map<String, Object> notAList = body("x", null);
|
||||
notAList.put("cells", "nope");
|
||||
assertThatThrownBy(() -> service.create(notAList, caller(READER))).isInstanceOf(QBadRequestException.class);
|
||||
Map<String, Object> notObjects = body("x", null);
|
||||
notObjects.put("cells", List.of("nope"));
|
||||
assertThatThrownBy(() -> service.create(notObjects, caller(READER))).isInstanceOf(QBadRequestException.class);
|
||||
|
||||
List<Map<String, Object>> tooMany = new ArrayList<>();
|
||||
for(int i = 0; i <= NotebookService.MAX_CELLS; i++)
|
||||
{
|
||||
tooMany.add(cell("k" + i, "SQL", "SELECT 1"));
|
||||
}
|
||||
assertThatThrownBy(() -> service.create(body("x", tooMany), caller(READER))).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> service.get(null, caller(READER))).isInstanceOf(QBadRequestException.class);
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// duplicate or missing keys are replaced by UUIDs //
|
||||
///////////////////////////////////////////////////////
|
||||
List<SqlWorkbenchCell> cells = service.parseCells(List.of(cell("same", "SQL", "1"), cell("same", "SQL", "2"), cell(null, null, null)));
|
||||
assertThat(cells).extracting(SqlWorkbenchCell::getCellKey).doesNotHaveDuplicates();
|
||||
assertThat(cells.get(2).getCellType()).isEqualTo("SQL");
|
||||
assertThat(cells.get(2).getContent()).isEmpty();
|
||||
assertThat(NotebookService.truncate("abcdef", 4)).isEqualTo("abc…");
|
||||
assertThat(NotebookService.parseParameters("not json")).isEmpty();
|
||||
assertThat(NotebookService.parseJsonObject(null)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testSnapshots() throws Exception
|
||||
{
|
||||
Integer id = (Integer) service.create(body("Snap", List.of(cell("c1", "SQL", "SELECT * FROM person"))), caller(READER)).get("id");
|
||||
|
||||
SqlExecutionEngine engine = new SqlExecutionEngine(qInstance, SqlWorkbenchRuntime.get(qInstance));
|
||||
Map<String, Object> response = engine.execute(ExecutionRequest.readOnly(BACKEND_NAME, "SELECT * FROM person", Map.of()), caller(READER));
|
||||
String uuid = (String) response.get("executionUuid");
|
||||
|
||||
assertThatThrownBy(() -> service.createSnapshot(id, Map.of("executionUuid", "missing"), caller(READER), null)).isInstanceOf(QNotFoundException.class);
|
||||
|
||||
Map<String, Object> snapshot = service.createSnapshot(id, Map.of("executionUuid", uuid, "cellKey", "c1", "label", "people"), caller(READER), "SELECT * FROM person");
|
||||
assertThat(snapshot.get("rowCount")).isEqualTo(2);
|
||||
assertThat(snapshot.get("truncated")).isEqualTo(true);
|
||||
assertThat(((List<?>) ((Map<String, Object>) snapshot.get("result")).get("rows"))).hasSize(2);
|
||||
|
||||
List<Map<String, Object>> snapshots = service.snapshots(id, caller(READER));
|
||||
assertThat(snapshots).hasSize(1);
|
||||
|
||||
Integer snapshotId = (Integer) snapshot.get("id");
|
||||
assertThatThrownBy(() -> service.deleteSnapshot(id, 999, caller(READER))).isInstanceOf(QNotFoundException.class);
|
||||
service.deleteSnapshot(id, snapshotId, caller(READER));
|
||||
assertThat(service.snapshots(id, caller(READER))).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.sqlworkbench.sql;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests for SqlScanner, SqlClassifier and NamedParameterSql.
|
||||
*******************************************************************************/
|
||||
class SqlClassifierTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static StatementKind kindOf(String sql, SqlDialect dialect)
|
||||
{
|
||||
List<SqlStatement> statements = SqlClassifier.split(sql, dialect);
|
||||
assertThat(statements).hasSize(1);
|
||||
return (statements.get(0).kind());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', quoteCharacter = '~', value = {
|
||||
"SELECT * FROM person | READ",
|
||||
"select 1 | READ",
|
||||
"(SELECT 1) UNION (SELECT 2) | READ",
|
||||
"WITH x AS (SELECT 1) SELECT * FROM x | READ",
|
||||
"VALUES (1), (2) | READ",
|
||||
"SHOW search_path | READ",
|
||||
"EXPLAIN SELECT 1 | READ",
|
||||
"EXPLAIN (FORMAT JSON) SELECT 1 | READ",
|
||||
"EXPLAIN (ANALYZE false) SELECT 1 | READ",
|
||||
"EXPLAIN ANALYZE SELECT 1 | EXPLAIN_ANALYZE",
|
||||
"EXPLAIN (ANALYZE, BUFFERS) SELECT 1 | EXPLAIN_ANALYZE",
|
||||
"SELECT 'delete from x' AS s | READ",
|
||||
"SELECT \"update\" FROM t | READ",
|
||||
"SELECT last_update, created FROM t | READ",
|
||||
"SELECT * FROM t FOR UPDATE | WRITE",
|
||||
"SELECT * INTO new_t FROM t | WRITE",
|
||||
"WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d | WRITE",
|
||||
"SELECT nextval('seq') | WRITE",
|
||||
"SELECT nextval_count FROM t | READ",
|
||||
"INSERT INTO t VALUES (1) | WRITE",
|
||||
"update t set a = 1 | WRITE",
|
||||
"DELETE FROM t | WRITE",
|
||||
"CREATE TABLE x (id int) | DDL",
|
||||
"DROP TABLE x | DDL",
|
||||
"TRUNCATE t | DDL",
|
||||
"BEGIN | FORBIDDEN",
|
||||
"COMMIT | FORBIDDEN",
|
||||
"SET search_path = evil | FORBIDDEN",
|
||||
"RESET ALL | FORBIDDEN",
|
||||
"DO $$ BEGIN END $$ | FORBIDDEN",
|
||||
"SELECT 1 -- DELETE | READ",
|
||||
"/* DELETE */ SELECT 1 | READ",
|
||||
"42 | WRITE",
|
||||
})
|
||||
void testPostgresClassification(String sql, StatementKind expected)
|
||||
{
|
||||
assertThat(kindOf(sql.replace("\\n", "\n"), SqlDialect.POSTGRES)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOtherDialects()
|
||||
{
|
||||
assertThat(kindOf("SELECT 1 # DELETE", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT 'it\\'s DELETE' AS x", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT `delete` FROM t", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT \"delete\" AS s", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("USE other_db", SqlDialect.MYSQL)).isEqualTo(StatementKind.FORBIDDEN);
|
||||
assertThat(kindOf("PRAGMA table_info(t)", SqlDialect.SQLITE)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("PRAGMA foreign_keys = OFF", SqlDialect.SQLITE)).isEqualTo(StatementKind.FORBIDDEN);
|
||||
assertThat(kindOf("SELECT [delete] FROM t", SqlDialect.SQLITE)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("ATTACH 'x.db' AS x", SqlDialect.SQLITE)).isEqualTo(StatementKind.FORBIDDEN);
|
||||
assertThat(kindOf("SHUTDOWN", SqlDialect.H2)).isEqualTo(StatementKind.FORBIDDEN);
|
||||
assertThat(kindOf("SCRIPT TO 'x.sql'", SqlDialect.H2)).isEqualTo(StatementKind.FORBIDDEN);
|
||||
assertThat(kindOf("SELECT 1", null)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("-- leading comment\nSELECT 1", SqlDialect.POSTGRES)).isEqualTo(StatementKind.READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MySQL runs /*! ... *\/ (and MariaDB /*M! ... *\/) as SQL and reads
|
||||
** optimizer hints from /*+ ... *\/: on MySQL these fail closed.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource(delimiter = '|', quoteCharacter = '~', value = {
|
||||
"SELECT * FROM t /*! INTO OUTFILE '/tmp/x' */",
|
||||
"SELECT * FROM t /*!50000 INTO OUTFILE '/tmp/x' */",
|
||||
"SELECT * FROM t /*M! INTO OUTFILE '/tmp/x' */",
|
||||
"/*! DROP TABLE t */",
|
||||
"SELECT /*+ MAX_EXECUTION_TIME(1) */ * FROM t",
|
||||
"SELECT /*+ SET_VAR(sql_mode='') */ 1",
|
||||
"SELECT 1 /*!",
|
||||
})
|
||||
void testMysqlExecutableCommentsAreForbidden(String sql)
|
||||
{
|
||||
List<SqlStatement> statements = SqlClassifier.split(sql, SqlDialect.MYSQL);
|
||||
assertThat(statements).hasSize(1);
|
||||
assertThat(statements.get(0).kind()).isEqualTo(StatementKind.FORBIDDEN);
|
||||
assertThat(statements.get(0).reason()).contains("executable comments");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain comments stay comments; the executable forms only matter on
|
||||
** MySQL; inside literals they are text.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMysqlPlainCommentsAndOtherDialects()
|
||||
{
|
||||
assertThat(kindOf("SELECT 1 /* INTO OUTFILE */", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT '/*! INTO OUTFILE */' AS s", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT `/*!x*/` FROM t", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT 1 /*! INTO OUTFILE */", SqlDialect.POSTGRES)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT 1 /*+ hint */", SqlDialect.H2)).isEqualTo(StatementKind.READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MySQL only treats "--" as a comment when whitespace follows; "1--1" is
|
||||
** arithmetic, so what follows it is still SQL.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMysqlDashDashNeedsWhitespace()
|
||||
{
|
||||
assertThat(kindOf("SELECT * FROM t WHERE a=1--1 INTO OUTFILE '/tmp/x'", SqlDialect.MYSQL)).isEqualTo(StatementKind.WRITE);
|
||||
assertThat(kindOf("SELECT 1 -- INTO OUTFILE", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT 1 --\tINTO", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT 1 --", SqlDialect.MYSQL)).isEqualTo(StatementKind.READ);
|
||||
assertThat(kindOf("SELECT 1 --INTO x", SqlDialect.POSTGRES)).isEqualTo(StatementKind.READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSplitting()
|
||||
{
|
||||
List<SqlStatement> statements = SqlClassifier.split("SELECT 1; SELECT ';'; ; -- only a comment\n", SqlDialect.POSTGRES);
|
||||
assertThat(statements).hasSize(2);
|
||||
assertThat(statements.get(0).text()).isEqualTo("SELECT 1");
|
||||
assertThat(statements.get(1).text()).isEqualTo("SELECT ';'");
|
||||
|
||||
List<SqlStatement> dollar = SqlClassifier.split("CREATE FUNCTION f() RETURNS int AS $body$ SELECT 1; $body$ LANGUAGE sql; SELECT 2", SqlDialect.POSTGRES);
|
||||
assertThat(dollar).hasSize(2);
|
||||
assertThat(dollar.get(0).kind()).isEqualTo(StatementKind.DDL);
|
||||
|
||||
assertThat(SqlClassifier.split("/* nested /* comment */ still */ SELECT 1", SqlDialect.POSTGRES)).hasSize(1).first().extracting(SqlStatement::kind).isEqualTo(StatementKind.READ);
|
||||
assertThat(SqlClassifier.split("", SqlDialect.POSTGRES)).isEmpty();
|
||||
assertThat(SqlClassifier.split(null, SqlDialect.POSTGRES)).isEmpty();
|
||||
assertThat(SqlClassifier.split("SELECT 'unterminated", SqlDialect.POSTGRES)).hasSize(1);
|
||||
assertThat(SqlClassifier.split("SELECT $$unterminated", SqlDialect.POSTGRES)).hasSize(1);
|
||||
assertThat(SqlClassifier.split("SELECT 1 /* unterminated", SqlDialect.POSTGRES)).hasSize(1);
|
||||
assertThat(SqlClassifier.split("SELECT E'a\\'; DELETE' x", SqlDialect.POSTGRES)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNamedParameters()
|
||||
{
|
||||
SqlStatement statement = SqlClassifier.split("SELECT id::text, ':skip' FROM t WHERE a = :a AND b = :b_2 OR a = :a -- :c", SqlDialect.POSTGRES).get(0);
|
||||
assertThat(statement.parameterNames()).containsExactly("a", "b_2", "a");
|
||||
|
||||
NamedParameterSql named = NamedParameterSql.of(statement);
|
||||
assertThat(named.jdbcSql()).isEqualTo("SELECT id::text, ':skip' FROM t WHERE a = ? AND b = ? OR a = ?");
|
||||
assertThat(named.parameterOrder()).containsExactly("a", "b_2", "a");
|
||||
|
||||
NamedParameterSql none = NamedParameterSql.of(SqlClassifier.split("SELECT 1", SqlDialect.POSTGRES).get(0));
|
||||
assertThat(none.jdbcSql()).isEqualTo("SELECT 1");
|
||||
assertThat(none.parameterOrder()).isEmpty();
|
||||
|
||||
assertThat(SqlClassifier.split("SELECT $1, 1.5e3, x$y", SqlDialect.POSTGRES).get(0).kind()).isEqualTo(StatementKind.READ);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDialectMapping()
|
||||
{
|
||||
assertThat(SqlDialect.fromVendor("postgres")).isEqualTo(SqlDialect.POSTGRES);
|
||||
assertThat(SqlDialect.fromVendor("aurora-postgresql")).isEqualTo(SqlDialect.POSTGRES);
|
||||
assertThat(SqlDialect.fromVendor("mysql")).isEqualTo(SqlDialect.MYSQL);
|
||||
assertThat(SqlDialect.fromVendor("sqlite")).isEqualTo(SqlDialect.SQLITE);
|
||||
assertThat(SqlDialect.fromVendor("h2")).isEqualTo(SqlDialect.H2);
|
||||
assertThat(SqlDialect.fromVendor("oracle")).isEqualTo(SqlDialect.GENERIC);
|
||||
assertThat(SqlDialect.fromVendor(null)).isEqualTo(SqlDialect.GENERIC);
|
||||
assertThat(SqlDialect.POSTGRES.getUiName()).isEqualTo("postgresql");
|
||||
assertThat(SqlDialect.POSTGRES.isTransactionalDdl()).isTrue();
|
||||
assertThat(SqlDialect.MYSQL.isTransactionalDdl()).isFalse();
|
||||
assertThat(StatementKind.READ.isReadOnlySafe()).isTrue();
|
||||
assertThat(StatementKind.DDL.isMutating()).isTrue();
|
||||
assertThat(StatementKind.FORBIDDEN.isMutating()).isFalse();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
-- H2 (MODE=MySQL) version of docs/ddl/postgres.sql, for unit tests.
|
||||
DROP TABLE IF EXISTS sql_workbench_result_snapshot;
|
||||
DROP TABLE IF EXISTS sql_workbench_notebook_version;
|
||||
DROP TABLE IF EXISTS sql_workbench_cell;
|
||||
DROP TABLE IF EXISTS sql_workbench_notebook;
|
||||
DROP TABLE IF EXISTS sql_workbench_execution;
|
||||
DROP TABLE IF EXISTS person;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(1000000),
|
||||
owner_user_id VARCHAR(255) NOT NULL,
|
||||
owner_name VARCHAR(255),
|
||||
visibility VARCHAR(16) NOT NULL DEFAULT 'PERSONAL',
|
||||
default_backend_name VARCHAR(100),
|
||||
parameters_json VARCHAR(1000000),
|
||||
current_version INTEGER,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_sql_workbench_notebook PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_owner ON sql_workbench_notebook (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_notebook_visibility ON sql_workbench_notebook (visibility);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_cell (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
cell_key VARCHAR(64) NOT NULL,
|
||||
sort_order INTEGER,
|
||||
cell_type VARCHAR(16) NOT NULL,
|
||||
title VARCHAR(255),
|
||||
content VARCHAR(1000000),
|
||||
backend_name VARCHAR(100),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_sql_workbench_cell PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_cell_key UNIQUE (notebook_id, cell_key),
|
||||
CONSTRAINT fk_sql_workbench_cell_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_notebook_version (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
content_json VARCHAR(1000000),
|
||||
saved_by_user_id VARCHAR(255),
|
||||
saved_by_name VARCHAR(255),
|
||||
comment VARCHAR(255),
|
||||
create_date TIMESTAMP,
|
||||
CONSTRAINT pk_sql_workbench_notebook_version PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_notebook_version UNIQUE (notebook_id, version_number),
|
||||
CONSTRAINT fk_sql_workbench_notebook_version_notebook FOREIGN KEY (notebook_id) REFERENCES sql_workbench_notebook (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_result_snapshot (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL,
|
||||
notebook_id INTEGER NOT NULL,
|
||||
cell_key VARCHAR(64),
|
||||
execution_uuid VARCHAR(36),
|
||||
label VARCHAR(255),
|
||||
backend_name VARCHAR(100),
|
||||
sql_text VARCHAR(1000000),
|
||||
result_json VARCHAR(1000000),
|
||||
row_count INTEGER,
|
||||
truncated BOOLEAN,
|
||||
created_by_user_id VARCHAR(255),
|
||||
create_date TIMESTAMP,
|
||||
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)
|
||||
);
|
||||
|
||||
-- Audit: append-only. Not foreign-keyed to notebooks, so deleting a
|
||||
-- notebook never deletes its audit trail.
|
||||
CREATE TABLE IF NOT EXISTS sql_workbench_execution (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL,
|
||||
execution_uuid VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(255),
|
||||
user_name VARCHAR(255),
|
||||
backend_name VARCHAR(100),
|
||||
mode VARCHAR(24),
|
||||
status VARCHAR(16),
|
||||
sql_text VARCHAR(1000000),
|
||||
sql_hash VARCHAR(64),
|
||||
parameters_json VARCHAR(1000000),
|
||||
statement_count INTEGER,
|
||||
statement_kinds VARCHAR(255),
|
||||
notebook_id INTEGER,
|
||||
cell_key VARCHAR(64),
|
||||
start_time TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
duration_ms INTEGER,
|
||||
rows_returned INTEGER,
|
||||
rows_affected INTEGER,
|
||||
truncated BOOLEAN,
|
||||
error_message VARCHAR(1000000),
|
||||
client_ip VARCHAR(64),
|
||||
create_date TIMESTAMP,
|
||||
CONSTRAINT pk_sql_workbench_execution PRIMARY KEY (id),
|
||||
CONSTRAINT uq_sql_workbench_execution_uuid UNIQUE (execution_uuid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_execution_user ON sql_workbench_execution (user_id, id);
|
||||
CREATE INDEX IF NOT EXISTS ix_sql_workbench_execution_start ON sql_workbench_execution (start_time);
|
||||
|
||||
CREATE TABLE person (
|
||||
id INTEGER AUTO_INCREMENT NOT NULL PRIMARY KEY,
|
||||
first_name VARCHAR(80),
|
||||
last_name VARCHAR(80),
|
||||
birth_date DATE,
|
||||
salary DECIMAL(12, 2),
|
||||
active BOOLEAN,
|
||||
photo VARBINARY(1000),
|
||||
create_date TIMESTAMP
|
||||
);
|
||||
INSERT INTO person (first_name, last_name, birth_date, salary, active, photo, create_date) VALUES
|
||||
('Darin', 'Kelkhoff', DATE '1980-05-31', 25000.00, TRUE, X'CAFEBABE', TIMESTAMP '2026-01-01 10:00:00'),
|
||||
('James', 'Maes', DATE '1981-02-02', 26000.50, TRUE, NULL, TIMESTAMP '2026-01-02 11:00:00'),
|
||||
('Tim', 'Chamberlain', DATE '1976-03-03', NULL, FALSE, NULL, TIMESTAMP '2026-01-03 12:00:00'),
|
||||
('Tyler', 'Samples', NULL, 30000.00, TRUE, NULL, NULL),
|
||||
('Garret', 'Richardson', DATE '1985-04-04', 19000.00, NULL, NULL, TIMESTAMP '2026-01-05 14:00:00');
|
||||
Reference in new issue
Block a user