From e0d9312affd1a853e7e59652d4d110c80f71ac49 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 10:38:51 -0500 Subject: [PATCH 1/2] feat: SQL Workbench record developer panel Add SqlWorkbenchDeveloperPanelProvider (id sqlWorkbench, order 450, icon terminal), registered by SqlWorkbenchDeveloperPanelsMetaDataProducer via DeveloperPanelsMetaData. For tables on an allowed RDBMS backend, and users with the workbench access permission, it shows read-only SELECTs for the record and its table in the backend's own table/column names (dialect quoted identifiers, escaped primary-key literal), backend details, and links that open each query in the workbench (?sql=&backend=, not run). Build against QQQ 4.1.0-RC.4 (the developer panel contract). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- CHANGELOG.md | 8 + CLAUDE.md | 3 +- README.md | 14 +- pom.xml | 9 +- .../sqlworkbench/developer/RecordSql.java | 160 +++++++++++ .../SqlWorkbenchDeveloperPanelProvider.java | 267 +++++++++++++++++ ...kbenchDeveloperPanelsMetaDataProducer.java | 47 +++ ...qlWorkbenchDeveloperPanelProviderTest.java | 271 ++++++++++++++++++ 9 files changed, 774 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/developer/RecordSql.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProvider.java create mode 100644 src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer.java create mode 100644 src/test/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProviderTest.java diff --git a/AGENTS.md b/AGENTS.md index 3a16657..f527c27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ How tests are split: - `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. +- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals). **Invariants (do not break):** - Never log SQL text, parameters, or results. Only the audit table holds them. @@ -58,7 +59,7 @@ How tests are split: ## Dependencies - Java 21. -- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. +- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. - `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional). - Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4. diff --git a/CHANGELOG.md b/CHANGELOG.md index 893d28d..41f30c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning. +## [Unreleased] + +### Added +- Record developer panel "SQL Workbench" (`SqlWorkbenchDeveloperPanelProvider`, registered by `SqlWorkbenchDeveloperPanelsMetaDataProducer`): read-only `SELECT`s for the record and its table in the backend's own names, backend details, and links that open them in the workbench (`?sql=&backend=`). Requires QQQ 4.1.0-RC.4 (the developer panel contract) and a Next UI with the workbench `?sql=` support. + +### Changed +- Built against QQQ 4.1.0-RC.4. + ## [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). diff --git a/CLAUDE.md b/CLAUDE.md index 08e3ea5..4869773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ How tests are split: - `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. +- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals). **Invariants (do not break):** - Never log SQL text, parameters, or results. Only the audit table holds them. @@ -58,7 +59,7 @@ How tests are split: ## Dependencies - Java 21. -- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. +- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. - `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional). - Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4. diff --git a/README.md b/README.md index a2229c0..03c006f 100644 --- a/README.md +++ b/README.md @@ -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.2`). +- Java 21 and QQQ 4.1 (built against `4.1.0-RC.4`). - `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. @@ -87,6 +87,16 @@ Grant `sqlWorkbenchAdmin.hasAccess` only to administrators or auditors. Ordinary `writeModeEnabled=false` turns write mode off for everyone. +## Record developer panel + +With QQQ 4.1.0-RC.4 or later, the qbit registers a **SQL Workbench** panel for a record's developer view (`/app/{table}/{id}/dev` in the Next UI). It shows for tables on an RDBMS backend the workbench targets (honoring `allowedBackendNames` / `excludedBackendNames`), and only to users with `sqlWorkbench.hasAccess`. It contains: + +- A read-only `SELECT` for the record and one for the table (`LIMIT 100`), in the backend's own table and column names (aliased to the QQQ field names), with identifiers quoted and the primary key written as an escaped literal for the dialect. +- The backend, dialect, database table, and whether read-only sessions use the backend's read-only replica. +- Links that open each query in the workbench (`/app/sqlWorkbench?sql=...&backend=...`, with the table name prefix applied). The workbench opens the SQL as a new cell and does not run it. + +The panel only builds text. What runs is what the user executes, under their workbench permissions; the write permission does not change the panel. + ## 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. @@ -153,7 +163,7 @@ A checklist for adding the workbench to a QQQ application. Each row points to th | Item | What to do | |---|---| | Coordinates | `com.kingsrook.qbits:qbit-sql-workbench`. Current release candidate: `1.0.0-RC.1` (Maven Central, published from `release/1.0`). | -| QQQ version | QQQ 4.1, `4.1.0-RC.2` or later. Java 21. | +| QQQ version | QQQ 4.1, `4.1.0-RC.4` 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. | diff --git a/pom.xml b/pom.xml index 807d0d6..ebcc563 100644 --- a/pom.xml +++ b/pom.xml @@ -250,16 +250,17 @@ - - - + + + + qqq-snapshot true - 4.1.0-RC.2 + 4.1.0-RC.4 diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/developer/RecordSql.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/developer/RecordSql.java new file mode 100644 index 0000000..2b4c646 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/developer/RecordSql.java @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.sqlworkbench.developer; + + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect; +import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction; + + +/******************************************************************************* + ** Builds read-only SELECT statements for a QQQ table in its RDBMS backend's + ** own names (the table's backend table name and each field's backend column + ** name, as QQQ's RDBMS module resolves them), with identifiers quoted and + ** the primary-key value written as an escaped literal for the dialect. + ** + ** The SQL is only shown to the user (and opened, not run, in the + ** workbench), so it uses literals rather than parameters. + *******************************************************************************/ +public final class RecordSql +{ + public static final int TABLE_ROW_LIMIT = 100; + + + + /******************************************************************************* + ** + *******************************************************************************/ + private RecordSql() + { + } + + + + /******************************************************************************* + ** SELECT the table's columns for one primary-key value. + *******************************************************************************/ + public static String selectRecord(QTableMetaData table, SqlDialect dialect, Serializable primaryKey) + { + QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField()); + return (selectColumns(table, dialect) + + "\nWHERE " + quoteIdentifier(dialect, AbstractRDBMSAction.getColumnName(primaryKeyField)) + " = " + literal(dialect, primaryKeyField.getType(), primaryKey)); + } + + + + /******************************************************************************* + ** SELECT the table's columns, first rows only. + *******************************************************************************/ + public static String selectTable(QTableMetaData table, SqlDialect dialect) + { + String select = selectColumns(table, dialect); + if(dialect == SqlDialect.GENERIC) + { + return (select + "\nFETCH FIRST " + TABLE_ROW_LIMIT + " ROWS ONLY"); + } + return (select + "\nLIMIT " + TABLE_ROW_LIMIT); + } + + + + /******************************************************************************* + ** SELECT clause listing every field's column (aliased to the QQQ field + ** name where the names differ) and the FROM clause. + *******************************************************************************/ + static String selectColumns(QTableMetaData table, SqlDialect dialect) + { + List columns = new ArrayList<>(); + for(QFieldMetaData field : table.getFields().values()) + { + String column = AbstractRDBMSAction.getColumnName(field); + columns.add(column.equals(field.getName()) + ? quoteIdentifier(dialect, column) + : quoteIdentifier(dialect, column) + " AS " + quoteIdentifier(dialect, field.getName())); + } + + return ("SELECT\n " + String.join(",\n ", columns) + + "\nFROM " + quoteIdentifier(dialect, AbstractRDBMSAction.getTableName(table))); + } + + + + /******************************************************************************* + ** Quote an identifier: backticks for MySQL, double quotes otherwise (ANSI; + ** Postgres, SQLite and H2 all accept them). A quote character inside the + ** identifier is doubled. + *******************************************************************************/ + public static String quoteIdentifier(SqlDialect dialect, String identifier) + { + String quote = dialect == SqlDialect.MYSQL ? "`" : "\""; + return (quote + identifier.replace(quote, quote + quote) + quote); + } + + + + /******************************************************************************* + ** A value as a SQL literal: NULL; a plain number for numeric fields whose + ** value parses as one; otherwise a single-quoted string with quotes + ** doubled (and, for MySQL, backslashes escaped). + *******************************************************************************/ + public static String literal(SqlDialect dialect, QFieldType type, Serializable value) + { + if(value == null) + { + return ("NULL"); + } + + if(type == QFieldType.INTEGER || type == QFieldType.LONG || type == QFieldType.DECIMAL || value instanceof Number) + { + try + { + return (new BigDecimal(value.toString().trim()).toPlainString()); + } + catch(NumberFormatException e) + { + /////////////////////////////////////////////////////// + // not a number after all: fall through to a string // + /////////////////////////////////////////////////////// + } + } + + return (stringLiteral(dialect, value.toString())); + } + + + + /******************************************************************************* + ** A single-quoted string literal. + *******************************************************************************/ + public static String stringLiteral(SqlDialect dialect, String value) + { + String escaped = value; + if(dialect.isBackslashEscapes()) + { + escaped = escaped.replace("\\", "\\\\"); + } + return ("'" + escaped.replace("'", "''") + "'"); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProvider.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProvider.java new file mode 100644 index 0000000..cd3a545 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProvider.java @@ -0,0 +1,267 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.sqlworkbench.developer; + + +import java.io.Serializable; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelLink; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelProviderInterface; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelTone; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; +import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends; +import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer; +import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction; + + +/******************************************************************************* + ** Record developer panel "SQL Workbench": read-only SELECTs for the record + ** (and its table) in the backend's own table and column names, with links + ** that open them in the workbench as new cells (never run). + ** + ** Available when the workbench is in the instance (its runtime and app), the + ** user has the workbench access permission (the same check its endpoints + ** make), and the table's backend is an RDBMS backend the workbench allows. + ** The panel only builds text: nothing is executed, and the user's write + ** permission makes no difference. + *******************************************************************************/ +public class SqlWorkbenchDeveloperPanelProvider implements DeveloperPanelProviderInterface +{ + public static final String ID = "sqlWorkbench"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getId() + { + return (ID); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getTitle() + { + return ("SQL Workbench"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public Integer getOrder() + { + return (450); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getIcon() + { + return ("terminal"); + } + + + + /******************************************************************************* + ** Available for a table on an allowed RDBMS backend, to users with the + ** workbench access permission, when the workbench app exists. + *******************************************************************************/ + @Override + public boolean isAvailable(DeveloperPanelContext context) + { + return (resolve(context) != null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public DeveloperPanel build(DeveloperPanelContext context) throws QException + { + Resolved resolved = resolve(context); + if(resolved == null) + { + throw (new QNotFoundException("The SQL Workbench is not available for this table.")); + } + + QTableMetaData table = context.getTable(); + WorkbenchBackends.Target target = resolved.target(); + String appName = resolved.config().applyPrefix(SqlWorkbenchAppMetaDataProducer.APP_NAME); + Serializable primaryKey = context.getPrimaryKey(); + + DeveloperPanel panel = new DeveloperPanel() + .withDescription("Read-only SQL for this record on the " + target.name() + " backend, in the database's own table and column names.") + .withStatusLabel("Read-only") + .withStatusTone(DeveloperPanelTone.INFO); + + DeveloperPanelBlock links = DeveloperPanelBlock.links("Open in SQL Workbench"); + + if(primaryKey != null) + { + String recordSql = RecordSql.selectRecord(table, target.dialect(), primaryKey); + panel.withBlock(DeveloperPanelBlock.code("Read-only query for this record", "sql", recordSql)); + links.withLink(new DeveloperPanelLink() + .withLabel("Open in SQL Workbench") + .withHref(workbenchHref(appName, recordSql, target.name())) + .withDescription("This record's query, as a new cell (not run)")); + } + + String tableSql = RecordSql.selectTable(table, target.dialect()); + panel.withBlock(DeveloperPanelBlock.code("Read-only query for this table (first " + RecordSql.TABLE_ROW_LIMIT + " rows)", "sql", tableSql)); + links.withLink(new DeveloperPanelLink() + .withLabel(primaryKey == null ? "Open in SQL Workbench" : "Open table query in SQL Workbench") + .withHref(workbenchHref(appName, tableSql, target.name())) + .withDescription("The table's query, as a new cell (not run)")); + + panel.withBlock(DeveloperPanelBlock.keyValues("Backend") + .withItem("Backend", target.name()) + .withItem("Dialect", target.dialect().getUiName()) + .withItem("Database table", AbstractRDBMSAction.getTableName(table)) + .withItem("Read-only replica", describeReadOnlySource(resolved))); + + panel.withBlock(links); + + panel.withBlock(DeveloperPanelBlock.text("The workbench runs only what you execute, under your SQL Workbench permissions. " + + "These queries are SELECTs; opening them does not run them.", DeveloperPanelTone.INFO)); + + return (panel); + } + + + + /******************************************************************************* + ** The workbench page with the SQL to open (as a new cell) and its backend. + *******************************************************************************/ + static String workbenchHref(String appName, String sql, String backendName) + { + return ("/app/" + encode(appName) + "?sql=" + encode(sql) + "&backend=" + encode(backendName)); + } + + + + /******************************************************************************* + ** URL-encode a query parameter (spaces as %20, not '+'). + *******************************************************************************/ + static String encode(String value) + { + return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")); + } + + + + /******************************************************************************* + ** Where read-only workbench sessions on the target connect, as + ** WorkbenchBackends.connectionSource resolves it. + *******************************************************************************/ + private String describeReadOnlySource(Resolved resolved) + { + if(resolved.backends().hasReadOnlyCredentials(resolved.target())) + { + return ("No (dedicated read-only credentials)"); + } + if(Boolean.TRUE.equals(resolved.config().getUseBackendReadOnlyReplica()) && resolved.target().backend().getReadOnlyBackendMetaData() != null) + { + return ("Yes (" + resolved.target().backend().getReadOnlyBackendMetaData().getName() + ")"); + } + return ("No"); + } + + + + /******************************************************************************* + ** The workbench config and target for the context's table, or null when + ** the panel is not available. + *******************************************************************************/ + private Resolved resolve(DeveloperPanelContext context) + { + QInstance qInstance = QContext.getQInstance(); + QSession session = QContext.getQSession(); + if(qInstance == null || session == null || context == null || context.getTable() == null) + { + return (null); + } + + QTableMetaData table = context.getTable(); + if(!StringUtils.hasContent(table.getBackendName()) || !StringUtils.hasContent(table.getPrimaryKeyField()) || table.getField(table.getPrimaryKeyField()) == null) + { + return (null); + } + + SqlWorkbenchRuntime runtime; + try + { + runtime = SqlWorkbenchRuntime.get(qInstance); + } + catch(QNotFoundException e) + { + return (null); + } + + SqlWorkbenchQBitConfig config = runtime.getConfig(); + if(qInstance.getApp(config.applyPrefix(SqlWorkbenchAppMetaDataProducer.APP_NAME)) == null || !session.hasPermission(config.getAccessPermissionName())) + { + return (null); + } + + WorkbenchBackends backends = new WorkbenchBackends(qInstance, runtime); + try + { + return (new Resolved(config, backends, backends.getTarget(table.getBackendName()))); + } + catch(QNotFoundException e) + { + return (null); + } + } + + + + /******************************************************************************* + ** The workbench config, backends and target for a table. + *******************************************************************************/ + private record Resolved(SqlWorkbenchQBitConfig config, WorkbenchBackends backends, WorkbenchBackends.Target target) + { + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer.java new file mode 100644 index 0000000..6c211ac --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.sqlworkbench.metadata; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelsMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.developer.SqlWorkbenchDeveloperPanelProvider; + + +/******************************************************************************* + ** Registers the "SQL Workbench" record developer panel + ** ({@link SqlWorkbenchDeveloperPanelProvider}) in the instance's developer + ** panels (idempotent). The provider decides per table and user whether it + ** shows. + *******************************************************************************/ +public class SqlWorkbenchDeveloperPanelsMetaDataProducer extends QBitComponentMetaDataProducer +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public DeveloperPanelsMetaData produce(QInstance qInstance) throws QException + { + return (DeveloperPanelsMetaData.ofOrWithNew(qInstance).withProvider(new QCodeReference(SqlWorkbenchDeveloperPanelProvider.class))); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProviderTest.java b/src/test/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProviderTest.java new file mode 100644 index 0000000..54b6bcf --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProviderTest.java @@ -0,0 +1,271 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.sqlworkbench.developer; + + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.developer.DeveloperPanelsAction; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlockType; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelLink; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelsMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qbits.sqlworkbench.BaseSqlWorkbenchTest; +import com.kingsrook.qbits.sqlworkbench.sql.SqlDialect; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Unit test for {@link SqlWorkbenchDeveloperPanelProvider} and + ** {@link RecordSql}. + *******************************************************************************/ +class SqlWorkbenchDeveloperPanelProviderTest extends BaseSqlWorkbenchTest +{ + private static final String TABLE_NAME = "orderLine"; + private static final String SKU = "AB'C-1"; + + + + /******************************************************************************* + ** A table whose backend names differ from its QQQ names, with a string + ** primary key, plus a table on a non-RDBMS backend. + *******************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + qInstance.addTable(new QTableMetaData() + .withName(TABLE_NAME) + .withBackendName(BACKEND_NAME) + .withPrimaryKeyField("sku") + .withBackendDetails(new RDBMSTableBackendDetails().withTableName("order_line")) + .withField(new QFieldMetaData("sku", QFieldType.STRING).withBackendName("line_sku")) + .withField(new QFieldMetaData("quantity", QFieldType.INTEGER)) + .withField(new QFieldMetaData("unitPrice", QFieldType.DECIMAL).withBackendName("unit_price"))); + + qInstance.addBackend(new QBackendMetaData().withName("memory")); + qInstance.addTable(new QTableMetaData() + .withName("memoryThing") + .withBackendName("memory") + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER))); + + try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement()) + { + statement.execute("DROP TABLE IF EXISTS order_line"); + statement.execute("CREATE TABLE order_line (line_sku VARCHAR(40) PRIMARY KEY, quantity INT, unit_price DECIMAL(10,2))"); + statement.execute("INSERT INTO order_line VALUES ('AB''C-1', 3, 9.50), ('other', 1, 1.00)"); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private DeveloperPanelContext context(String tableName, Object primaryKey) + { + QTableMetaData table = qInstance.getTable(tableName); + return (new DeveloperPanelContext().withTable(table).withRecord(new QRecord().withValue(table.getPrimaryKeyField(), (java.io.Serializable) primaryKey))); + } + + + + /******************************************************************************* + ** The producer registers the provider; the panel shows through the + ** framework action with the record and table queries, backend details, + ** links and the note. + *******************************************************************************/ + @Test + void testPanel() throws Exception + { + assertThat(DeveloperPanelsMetaData.of(qInstance).getProviders()) + .anyMatch(reference -> SqlWorkbenchDeveloperPanelProvider.class.getName().equals(reference.getName())); + + DeveloperPanel panel = new DeveloperPanelsAction().execute(context(TABLE_NAME, SKU)).stream() + .filter(p -> SqlWorkbenchDeveloperPanelProvider.ID.equals(p.getId())).findFirst().orElseThrow(); + assertNull(panel.getError()); + assertEquals("SQL Workbench", panel.getTitle()); + assertEquals(450, panel.getOrder()); + assertEquals("terminal", panel.getIcon()); + + List code = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.CODE).toList(); + assertEquals(2, code.size()); + assertEquals("sql", code.get(0).getLanguage()); + assertEquals(""" + SELECT + "line_sku" AS "sku", + "quantity", + "unit_price" AS "unitPrice" + FROM "order_line" + WHERE "line_sku" = 'AB''C-1'""", code.get(0).getCode()); + assertEquals(""" + SELECT + "line_sku" AS "sku", + "quantity", + "unit_price" AS "unitPrice" + FROM "order_line" + LIMIT 100""", code.get(1).getCode()); + + //////////////////////////////////////////////////////////// + // the generated SQL runs on the backend and finds the row // + //////////////////////////////////////////////////////////// + try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement()) + { + try(ResultSet resultSet = statement.executeQuery(code.get(0).getCode())) + { + assertTrue(resultSet.next()); + assertEquals(SKU, resultSet.getString("sku")); + assertEquals(3, resultSet.getInt("quantity")); + assertFalse(resultSet.next()); + } + try(ResultSet resultSet = statement.executeQuery(code.get(1).getCode())) + { + int rows = 0; + while(resultSet.next()) + { + rows++; + } + assertEquals(2, rows); + } + } + + DeveloperPanelBlock keyValues = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.KEY_VALUES).findFirst().orElseThrow(); + assertEquals(BACKEND_NAME, keyValues.getItems().get(0).getValue()); + assertEquals("h2", keyValues.getItems().get(1).getValue()); + assertEquals("order_line", keyValues.getItems().get(2).getValue()); + assertEquals("No", keyValues.getItems().get(3).getValue()); + + DeveloperPanelBlock links = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.LINKS).findFirst().orElseThrow(); + assertEquals(2, links.getLinks().size()); + DeveloperPanelLink recordLink = links.getLinks().get(0); + assertThat(recordLink.getHref()).startsWith("/app/sqlWorkbench?sql=").endsWith("&backend=h2").doesNotContain(" ", "+", "'", "\n"); + String sqlParam = recordLink.getHref().substring("/app/sqlWorkbench?sql=".length(), recordLink.getHref().indexOf("&backend=")); + assertEquals(code.get(0).getCode(), URLDecoder.decode(sqlParam, StandardCharsets.UTF_8)); + assertThat(links.getLinks().get(1).getHref()).contains("LIMIT%20100"); + + assertTrue(panel.getBlocks().stream().anyMatch(b -> b.getType() == DeveloperPanelBlockType.TEXT && b.getText().contains("runs only what you execute"))); + } + + + + /******************************************************************************* + ** A prefixed workbench links to its prefixed app; the replica is reported. + *******************************************************************************/ + @Test + void testPrefixAndReplica() throws Exception + { + config.setTableNamePrefix("ops"); + qInstance.getApp("sqlWorkbench").setName("ops_sqlWorkbench"); + qInstance.getApps().put("ops_sqlWorkbench", qInstance.getApps().remove("sqlWorkbench")); + config.setUseBackendReadOnlyReplica(true); + ((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)).setReadOnlyBackendMetaData(defineH2Backend("h2Replica")); + + DeveloperPanel panel = new SqlWorkbenchDeveloperPanelProvider().build(context("person", 7)); + String href = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.LINKS).findFirst().orElseThrow().getLinks().get(0).getHref(); + assertThat(href).startsWith("/app/ops_sqlWorkbench?sql="); + assertThat(panel.getBlocks().get(0).getCode()).endsWith("WHERE \"id\" = 7"); + DeveloperPanelBlock keyValues = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.KEY_VALUES).findFirst().orElseThrow(); + assertEquals("Yes (h2Replica)", keyValues.getItems().get(3).getValue()); + } + + + + /******************************************************************************* + ** Writers get the same (SELECT-only) panel. + *******************************************************************************/ + @Test + void testWriterSameQueries() throws Exception + { + String readerSql = new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode(); + loginAs(WRITER); + assertEquals(readerSql, new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode()); + } + + + + /******************************************************************************* + ** Not available without the access permission, for a non-RDBMS backend, + ** for an excluded or not-allowed backend, or without the app. + *******************************************************************************/ + @Test + void testNotAvailable() + { + SqlWorkbenchDeveloperPanelProvider provider = new SqlWorkbenchDeveloperPanelProvider(); + assertTrue(provider.isAvailable(context(TABLE_NAME, SKU))); + + loginAs("nobody-1"); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + assertTrue(new DeveloperPanelsAction().execute(context(TABLE_NAME, SKU)).stream().noneMatch(p -> SqlWorkbenchDeveloperPanelProvider.ID.equals(p.getId()))); + + loginAs(READER); + assertFalse(provider.isAvailable(context("memoryThing", 1))); + + config.setExcludedBackendNames(List.of(BACKEND_NAME)); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + + config.setExcludedBackendNames(null); + config.setAllowedBackendNames(List.of("someOtherBackend")); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + + config.setAllowedBackendNames(null); + qInstance.getApps().remove("sqlWorkbench"); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + } + + + + /******************************************************************************* + ** Identifier quoting and literals per dialect. + *******************************************************************************/ + @Test + void testQuotingAndLiterals() + { + assertEquals("`my``col`", RecordSql.quoteIdentifier(SqlDialect.MYSQL, "my`col")); + assertEquals("\"my\"\"col\"", RecordSql.quoteIdentifier(SqlDialect.POSTGRES, "my\"col")); + assertEquals("'a''b\\c'", RecordSql.stringLiteral(SqlDialect.POSTGRES, "a'b\\c")); + assertEquals("'a''b\\\\c'", RecordSql.stringLiteral(SqlDialect.MYSQL, "a'b\\c")); + assertEquals("42", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, 42)); + assertEquals("12.50", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.DECIMAL, new java.math.BigDecimal("12.50"))); + assertEquals("'1; DROP TABLE x'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, "1; DROP TABLE x")); + assertEquals("'42'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, "42")); + assertEquals("NULL", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, null)); + assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.GENERIC)).endsWith("FETCH FIRST 100 ROWS ONLY"); + assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.MYSQL)).contains("`first_name` AS `firstName`").endsWith("LIMIT 100"); + assertEquals("%27a%20b%27%26c%3D1", SqlWorkbenchDeveloperPanelProvider.encode("'a b'&c=1")); + } + +} -- 2.54.0 From ad8f9001a6148100d11b85eb01b24f188e3ae19e Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 12 Oct 2026 12:25:22 -0500 Subject: [PATCH 2/2] ci: rebuild now that QQQ 4.1.0-RC.4 is on Maven Central Co-Authored-By: Claude Opus 5.5 -- 2.54.0