diff --git a/AGENTS.md b/AGENTS.md index bcd9045..8d91acb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ How tests are split: - `SqlWorkbenchIntegrations`: opt-in ESB and Quick Search hooks for the main tables. - `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions. - `processes/`: the record-action steps (Share, Duplicate, Restore Version). +- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals). **Invariants (do not break):** - Never log SQL text, parameters, or results. Only the audit table holds them. @@ -64,7 +65,7 @@ How tests are split: ## Dependencies - Java 21. -- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. +- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. - `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional). - Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4. diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f72be..7910089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project are documented here. The format follows Keep - **Runner.** The notebook editor (`SQL_WORKBENCH` widget) moves to its own nav entry: the `sqlWorkbenchRunner` child app, labeled Runner. Open a notebook with `/app/sqlWorkbenchRunner?notebook=`. - On a notebook's record view: an Open in Runner widget, and a Runs child list (its executions the viewer may see) through the new join `sql_workbench_notebook_join_sql_workbench_execution`. - `SqlWorkbenchIntegrations`: opt-in ESB (`esbTableNames`) and Quick Search (`quickSearchTables`) hooks for the main tables, with no dependency on either module. +- **Record developer panel** "SQL Workbench" (`SqlWorkbenchDeveloperPanelProvider`, registered by `SqlWorkbenchDeveloperPanelsMetaDataProducer`): read-only `SELECT`s for the record and its table in the backend's own names, backend details, and links that open them in the Runner as a new cell (`/app/sqlWorkbenchRunner?sql=&backend=`, never run). Requires QQQ 4.1.0-RC.4 (the developer panel contract) and a Next UI with the Runner `?sql=` support. ### Changed - **The nav lists only the main tables**: Notebooks and the execution audit, after the Runner. Cells, versions, pinned results and shares are child lists on a notebook (still standard tables, reachable by URL and API). The app's widget list is now the dashboard, not the `SQL_WORKBENCH` widget; links to `/app/sqlWorkbench` that expected the editor should point at `/app/sqlWorkbenchRunner`. diff --git a/CLAUDE.md b/CLAUDE.md index 1e254de..fe2e1f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ How tests are split: - `SqlWorkbenchIntegrations`: opt-in ESB and Quick Search hooks for the main tables. - `customizers/`: table customizers holding the notebook invariants for every entry point; `SqlWorkbenchSecurity` puts the lock keys in sessions. - `processes/`: the record-action steps (Share, Duplicate, Restore Version). +- `developer/`: `SqlWorkbenchDeveloperPanelProvider` (the "SQL Workbench" record developer panel, registered by `metadata/SqlWorkbenchDeveloperPanelsMetaDataProducer`) and `RecordSql` (read-only SELECTs in backend names, dialect quoting and literals). **Invariants (do not break):** - Never log SQL text, parameters, or results. Only the audit table holds them. @@ -64,7 +65,7 @@ How tests are split: ## Dependencies - Java 21. -- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.2` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. +- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it (RC.4 brings the record developer panel contract), as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. - `qqq-backend-core`, `qqq-backend-module-rdbms`, and `qqq-middleware-javalin` (optional). - Tests: JUnit 5, AssertJ, Mockito, H2, `qqq-backend-module-sqlite`/`-postgres` (drivers), Testcontainers 1.21.4. diff --git a/README.md b/README.md index 1e68fdf..0f6fd77 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,16 @@ A session without the access permission gets no keys, so it finds no workbench r `writeModeEnabled=false` turns write mode off for everyone. +## Record developer panel + +With QQQ 4.1.0-RC.4 or later, the qbit registers a **SQL Workbench** panel for a record's developer view (`/app/{table}/{id}/dev` in the Next UI). It shows for tables on an RDBMS backend the workbench targets (honoring `allowedBackendNames` / `excludedBackendNames`), and only to users with `sql_workbench.hasAccess`. It contains: + +- A read-only `SELECT` for the record and one for the table (`LIMIT 100`), in the backend's own table and column names (aliased to the QQQ field names), with identifiers quoted and the primary key written as an escaped literal for the dialect. +- The backend, dialect, database table, and whether read-only sessions use the backend's read-only replica. +- Links that open each query in the workbench (`/app/sqlWorkbenchRunner?sql=...&backend=...`, the Runner, with the table name prefix applied). The Runner opens the SQL as a new cell and does not run it. + +The panel only builds text. What runs is what the user executes, under their workbench permissions; the write permission does not change the panel. + ## Administering through the tables Everything the Runner does can be done and inspected through the tables. Notebooks and the execution audit are in the SQL Workbench app's nav; the others open from a notebook's record view (or by URL). The same customizers run either way. diff --git a/pom.xml b/pom.xml index 1315520..ebcc563 100644 --- a/pom.xml +++ b/pom.xml @@ -250,9 +250,10 @@ - - - + + + + qqq-snapshot 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..256edde --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sqlworkbench/developer/SqlWorkbenchDeveloperPanelProvider.java @@ -0,0 +1,269 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.sqlworkbench.developer; + + +import java.io.Serializable; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanel; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelBlock; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelContext; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelLink; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelProviderInterface; +import com.kingsrook.qqq.backend.core.model.developer.DeveloperPanelTone; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchQBitConfig; +import com.kingsrook.qbits.sqlworkbench.SqlWorkbenchRuntime; +import com.kingsrook.qbits.sqlworkbench.engine.WorkbenchBackends; +import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchAppMetaDataProducer; +import com.kingsrook.qbits.sqlworkbench.metadata.SqlWorkbenchRunnerAppMetaDataProducer; +import com.kingsrook.qqq.backend.module.rdbms.actions.AbstractRDBMSAction; + + +/******************************************************************************* + ** Record developer panel "SQL Workbench": read-only SELECTs for the record + ** (and its table) in the backend's own table and column names, with links + ** that open them in the workbench as new cells (never run). + ** + ** Available when the workbench is in the instance (its runtime and app), the + ** user has the workbench access permission (the same check its endpoints + ** make), and the table's backend is an RDBMS backend the workbench allows. + ** The panel only builds text: nothing is executed, and the user's write + ** permission makes no difference. + *******************************************************************************/ +public class SqlWorkbenchDeveloperPanelProvider implements DeveloperPanelProviderInterface +{ + public static final String ID = "sqlWorkbench"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getId() + { + return (ID); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getTitle() + { + return ("SQL Workbench"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public Integer getOrder() + { + return (450); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getIcon() + { + return ("terminal"); + } + + + + /******************************************************************************* + ** Available for a table on an allowed RDBMS backend, to users with the + ** workbench access permission, when the workbench app exists. + *******************************************************************************/ + @Override + public boolean isAvailable(DeveloperPanelContext context) + { + return (resolve(context) != null); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public DeveloperPanel build(DeveloperPanelContext context) throws QException + { + Resolved resolved = resolve(context); + if(resolved == null) + { + throw (new QNotFoundException("The SQL Workbench is not available for this table.")); + } + + QTableMetaData table = context.getTable(); + WorkbenchBackends.Target target = resolved.target(); + String runnerApp = resolved.config().applyPrefix(SqlWorkbenchRunnerAppMetaDataProducer.APP_NAME); + Serializable primaryKey = context.getPrimaryKey(); + + DeveloperPanel panel = new DeveloperPanel() + .withDescription("Read-only SQL for this record on the " + target.name() + " backend, in the database's own table and column names.") + .withStatusLabel("Read-only") + .withStatusTone(DeveloperPanelTone.INFO); + + DeveloperPanelBlock links = DeveloperPanelBlock.links("Open in SQL Workbench"); + + if(primaryKey != null) + { + String recordSql = RecordSql.selectRecord(table, target.dialect(), primaryKey); + panel.withBlock(DeveloperPanelBlock.code("Read-only query for this record", "sql", recordSql)); + links.withLink(new DeveloperPanelLink() + .withLabel("Open in SQL Workbench") + .withHref(workbenchHref(runnerApp, recordSql, target.name())) + .withDescription("This record's query, as a new cell (not run)")); + } + + String tableSql = RecordSql.selectTable(table, target.dialect()); + panel.withBlock(DeveloperPanelBlock.code("Read-only query for this table (first " + RecordSql.TABLE_ROW_LIMIT + " rows)", "sql", tableSql)); + links.withLink(new DeveloperPanelLink() + .withLabel(primaryKey == null ? "Open in SQL Workbench" : "Open table query in SQL Workbench") + .withHref(workbenchHref(runnerApp, tableSql, target.name())) + .withDescription("The table's query, as a new cell (not run)")); + + panel.withBlock(DeveloperPanelBlock.keyValues("Backend") + .withItem("Backend", target.name()) + .withItem("Dialect", target.dialect().getUiName()) + .withItem("Database table", AbstractRDBMSAction.getTableName(table)) + .withItem("Read-only replica", describeReadOnlySource(resolved))); + + panel.withBlock(links); + + panel.withBlock(DeveloperPanelBlock.text("The workbench runs only what you execute, under your SQL Workbench permissions. " + + "These queries are SELECTs; opening them does not run them.", DeveloperPanelTone.INFO)); + + return (panel); + } + + + + /******************************************************************************* + ** The Runner page with the SQL to open (as a new cell, never run) and its + ** backend. + *******************************************************************************/ + static String workbenchHref(String appName, String sql, String backendName) + { + return ("/app/" + encode(appName) + "?sql=" + encode(sql) + "&backend=" + encode(backendName)); + } + + + + /******************************************************************************* + ** URL-encode a query parameter (spaces as %20, not '+'). + *******************************************************************************/ + static String encode(String value) + { + return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")); + } + + + + /******************************************************************************* + ** Where read-only workbench sessions on the target connect, as + ** WorkbenchBackends.connectionSource resolves it. + *******************************************************************************/ + private String describeReadOnlySource(Resolved resolved) + { + if(resolved.backends().hasReadOnlyCredentials(resolved.target())) + { + return ("No (dedicated read-only credentials)"); + } + if(Boolean.TRUE.equals(resolved.config().getUseBackendReadOnlyReplica()) && resolved.target().backend().getReadOnlyBackendMetaData() != null) + { + return ("Yes (" + resolved.target().backend().getReadOnlyBackendMetaData().getName() + ")"); + } + return ("No"); + } + + + + /******************************************************************************* + ** The workbench config and target for the context's table, or null when + ** the panel is not available. + *******************************************************************************/ + private Resolved resolve(DeveloperPanelContext context) + { + QInstance qInstance = QContext.getQInstance(); + QSession session = QContext.getQSession(); + if(qInstance == null || session == null || context == null || context.getTable() == null) + { + return (null); + } + + QTableMetaData table = context.getTable(); + if(!StringUtils.hasContent(table.getBackendName()) || !StringUtils.hasContent(table.getPrimaryKeyField()) || table.getField(table.getPrimaryKeyField()) == null) + { + return (null); + } + + SqlWorkbenchRuntime runtime; + try + { + runtime = SqlWorkbenchRuntime.get(qInstance); + } + catch(QNotFoundException e) + { + return (null); + } + + SqlWorkbenchQBitConfig config = runtime.getConfig(); + if(qInstance.getApp(config.applyPrefix(SqlWorkbenchAppMetaDataProducer.APP_NAME)) == null || !session.hasPermission(config.getAccessPermissionName())) + { + return (null); + } + + WorkbenchBackends backends = new WorkbenchBackends(qInstance, runtime); + try + { + return (new Resolved(config, backends, backends.getTarget(table.getBackendName()))); + } + catch(QNotFoundException e) + { + return (null); + } + } + + + + /******************************************************************************* + ** The workbench config, backends and target for a table. + *******************************************************************************/ + private record Resolved(SqlWorkbenchQBitConfig config, WorkbenchBackends backends, WorkbenchBackends.Target target) + { + } + +} 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..7991cb2 --- /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/sqlWorkbenchRunner?sql=").endsWith("&backend=h2").doesNotContain(" ", "+", "'", "\n"); + String sqlParam = recordLink.getHref().substring("/app/sqlWorkbenchRunner?sql=".length(), recordLink.getHref().indexOf("&backend=")); + assertEquals(code.get(0).getCode(), URLDecoder.decode(sqlParam, StandardCharsets.UTF_8)); + assertThat(links.getLinks().get(1).getHref()).contains("LIMIT%20100"); + + assertTrue(panel.getBlocks().stream().anyMatch(b -> b.getType() == DeveloperPanelBlockType.TEXT && b.getText().contains("runs only what you execute"))); + } + + + + /******************************************************************************* + ** A prefixed workbench links to its prefixed Runner; the replica is reported. + *******************************************************************************/ + @Test + void testPrefixAndReplica() throws Exception + { + config.setTableNamePrefix("ops"); + qInstance.getApp("sqlWorkbench").setName("ops_sqlWorkbench"); + qInstance.getApps().put("ops_sqlWorkbench", qInstance.getApps().remove("sqlWorkbench")); + config.setUseBackendReadOnlyReplica(true); + ((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)).setReadOnlyBackendMetaData(defineH2Backend("h2Replica")); + + DeveloperPanel panel = new SqlWorkbenchDeveloperPanelProvider().build(context("person", 7)); + String href = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.LINKS).findFirst().orElseThrow().getLinks().get(0).getHref(); + assertThat(href).startsWith("/app/ops_sqlWorkbenchRunner?sql="); + assertThat(panel.getBlocks().get(0).getCode()).endsWith("WHERE \"id\" = 7"); + DeveloperPanelBlock keyValues = panel.getBlocks().stream().filter(b -> b.getType() == DeveloperPanelBlockType.KEY_VALUES).findFirst().orElseThrow(); + assertEquals("Yes (h2Replica)", keyValues.getItems().get(3).getValue()); + } + + + + /******************************************************************************* + ** Writers get the same (SELECT-only) panel. + *******************************************************************************/ + @Test + void testWriterSameQueries() throws Exception + { + String readerSql = new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode(); + loginAs(WRITER); + assertEquals(readerSql, new SqlWorkbenchDeveloperPanelProvider().build(context(TABLE_NAME, SKU)).getBlocks().get(0).getCode()); + } + + + + /******************************************************************************* + ** Not available without the access permission, for a non-RDBMS backend, + ** for an excluded or not-allowed backend, or without the app. + *******************************************************************************/ + @Test + void testNotAvailable() + { + SqlWorkbenchDeveloperPanelProvider provider = new SqlWorkbenchDeveloperPanelProvider(); + assertTrue(provider.isAvailable(context(TABLE_NAME, SKU))); + + loginAs("nobody-1"); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + assertTrue(new DeveloperPanelsAction().execute(context(TABLE_NAME, SKU)).stream().noneMatch(p -> SqlWorkbenchDeveloperPanelProvider.ID.equals(p.getId()))); + + loginAs(READER); + assertFalse(provider.isAvailable(context("memoryThing", 1))); + + config.setExcludedBackendNames(List.of(BACKEND_NAME)); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + + config.setExcludedBackendNames(null); + config.setAllowedBackendNames(List.of("someOtherBackend")); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + + config.setAllowedBackendNames(null); + qInstance.getApps().remove("sqlWorkbench"); + assertFalse(provider.isAvailable(context(TABLE_NAME, SKU))); + } + + + + /******************************************************************************* + ** Identifier quoting and literals per dialect. + *******************************************************************************/ + @Test + void testQuotingAndLiterals() + { + assertEquals("`my``col`", RecordSql.quoteIdentifier(SqlDialect.MYSQL, "my`col")); + assertEquals("\"my\"\"col\"", RecordSql.quoteIdentifier(SqlDialect.POSTGRES, "my\"col")); + assertEquals("'a''b\\c'", RecordSql.stringLiteral(SqlDialect.POSTGRES, "a'b\\c")); + assertEquals("'a''b\\\\c'", RecordSql.stringLiteral(SqlDialect.MYSQL, "a'b\\c")); + assertEquals("42", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, 42)); + assertEquals("12.50", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.DECIMAL, new java.math.BigDecimal("12.50"))); + assertEquals("'1; DROP TABLE x'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.INTEGER, "1; DROP TABLE x")); + assertEquals("'42'", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, "42")); + assertEquals("NULL", RecordSql.literal(SqlDialect.POSTGRES, QFieldType.STRING, null)); + assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.GENERIC)).endsWith("FETCH FIRST 100 ROWS ONLY"); + assertThat(RecordSql.selectTable(qInstance.getTable("person"), SqlDialect.MYSQL)).contains("`first_name` AS `firstName`").endsWith("LIMIT 100"); + assertEquals("%27a%20b%27%26c%3D1", SqlWorkbenchDeveloperPanelProvider.encode("'a b'&c=1")); + } + +}