From 668919a7c94bc944b3eabdfc4aefd65551b41715 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 7 Jul 2026 01:50:53 +0800 Subject: [PATCH] fix(gbase8s): make view source saves safer --- .../main/java/com/dbx/agent/ObjectSource.java | 24 +- .../com/dbx/agent/gbase8s/Gbase8sAgent.java | 283 ++++++++++++ .../dbx/agent/gbase8s/Gbase8sAgentTest.java | 161 ++++++- apps/desktop/src/App.vue | 13 +- .../src/components/editor/QueryEditor.vue | 26 +- .../src/components/objects/DdlViewDialog.vue | 6 +- .../src/components/objects/ObjectBrowser.vue | 71 +-- .../components/objects/ObjectSourceDialog.vue | 247 ++++++++++ .../src/components/sidebar/TreeItem.vue | 130 +++--- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../table/objectSourceEditor.spec.ts | 34 ++ .../src/lib/table/objectSourceEditor.ts | 20 + apps/desktop/src/types/database.ts | 1 + crates/dbx-core/src/db/rqlite_driver.rs | 2 +- crates/dbx-core/src/db/turso_driver.rs | 2 +- crates/dbx-core/src/object_source_sql.rs | 433 ++++++++++++++++++ crates/dbx-core/src/schema.rs | 1 + crates/dbx-core/src/transfer.rs | 9 +- crates/dbx-core/src/types.rs | 2 + 25 files changed, 1343 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/components/objects/ObjectSourceDialog.vue create mode 100644 apps/desktop/src/lib/__tests__/table/objectSourceEditor.spec.ts diff --git a/agents/common/src/main/java/com/dbx/agent/ObjectSource.java b/agents/common/src/main/java/com/dbx/agent/ObjectSource.java index 8ada3b610..13e9d2590 100644 --- a/agents/common/src/main/java/com/dbx/agent/ObjectSource.java +++ b/agents/common/src/main/java/com/dbx/agent/ObjectSource.java @@ -7,20 +7,26 @@ public final class ObjectSource { private String object_type; private String schema; private String source; + private boolean editable; public ObjectSource() { - this("", "", null, ""); + this("", "", null, "", true); } public ObjectSource(String name, String object_type, String source) { - this(name, object_type, null, source); + this(name, object_type, null, source, true); } public ObjectSource(String name, String object_type, String schema, String source) { + this(name, object_type, schema, source, true); + } + + public ObjectSource(String name, String object_type, String schema, String source, boolean editable) { this.name = name; this.object_type = object_type; this.schema = schema; this.source = source; + this.editable = editable; } public String getName() { @@ -39,6 +45,10 @@ public final class ObjectSource { return source; } + public boolean isEditable() { + return editable; + } + public void setName(String name) { this.name = name; } @@ -55,6 +65,10 @@ public final class ObjectSource { this.source = source; } + public void setEditable(boolean editable) { + this.editable = editable; + } + @Override public boolean equals(Object other) { if (this == other) return true; @@ -63,12 +77,13 @@ public final class ObjectSource { return Objects.equals(name, that.name) && Objects.equals(object_type, that.object_type) && Objects.equals(schema, that.schema) - && Objects.equals(source, that.source); + && Objects.equals(source, that.source) + && editable == that.editable; } @Override public int hashCode() { - return Objects.hash(name, object_type, schema, source); + return Objects.hash(name, object_type, schema, source, editable); } @Override @@ -77,6 +92,7 @@ public final class ObjectSource { + ", object_type=" + object_type + ", schema=" + schema + ", source=" + source + + ", editable=" + editable + ")"; } } diff --git a/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java b/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java index 1db8a20eb..8997c7f95 100644 --- a/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java +++ b/agents/drivers/gbase8s/src/main/java/com/dbx/agent/gbase8s/Gbase8sAgent.java @@ -2,11 +2,13 @@ package com.dbx.agent.gbase8s; import com.dbx.agent.ConfiguredJdbcAgent; import com.dbx.agent.ConnectParams; +import com.dbx.agent.ColumnInfo; import com.dbx.agent.DatabaseInfo; import com.dbx.agent.ExecuteQueryOptions; import com.dbx.agent.JdbcAgentProfile; import com.dbx.agent.JsonRpcServer; import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectSource; import com.dbx.agent.QueryResult; import com.dbx.agent.TableInfo; import java.sql.Connection; @@ -15,6 +17,7 @@ import java.sql.ResultSet; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -195,6 +198,73 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { return queryConstrainedTables(schema, normalized); } + @Override + public List getColumns(String schema, String table) { + try { + Connection conn = requireConnection(); + String owner = trim(schema); + Set primaryKeyColumns = getPrimaryKeyColumnNumbers(conn, owner, table); + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder(""" + SELECT c.colname, c.coltype, c.colno, c.collength + FROM syscolumns c + JOIN systables t ON t.tabid = c.tabid + WHERE t.tabid >= 100 AND t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + sql.append(" ORDER BY c.colno"); + + List result = new ArrayList<>(); + try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String name = trim(rs.getString("colname")); + int coltype = rs.getInt("coltype"); + int baseType = baseColType(coltype); + int length = rs.getInt("collength"); + result.add(new ColumnInfo( + name, + mapColType(baseType), + (coltype & 256) == 0, + null, + primaryKeyColumns.contains(rs.getInt("colno")), + null, + null, + numericPrecision(baseType, length), + numericScale(baseType, length), + characterMaximumLength(baseType, length) + )); + } + } + } + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + String normalizedType = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT); + if (!"VIEW".equals(normalizedType)) { + throw new UnsupportedOperationException("Object source is not supported"); + } + return new ObjectSource(name, "VIEW", emptyToNull(trim(schema)), viewSource(schema, name), isEditableView(schema, name)); + } + + @Override + public String getTableDdl(String schema, String table) { + if ("VIEW".equals(tableType(schema, table))) { + return viewSource(schema, table); + } + return super.getTableDdl(schema, table); + } + private List queryConstrainedTables(String schema, MetadataListConstraints constraints) { if (!constraints.includesTableLikeTypes()) { return List.of(); @@ -336,6 +406,211 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { return "V".equalsIgnoreCase(trim(tabtype)) ? "VIEW" : "TABLE"; } + private String tableType(String schema, String table) { + try { + String owner = trim(schema); + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder("SELECT tabtype FROM systables WHERE tabid >= 100 AND tabname = ?"); + if (!owner.isEmpty()) { + sql.append(" AND owner = ?"); + args.add(owner); + } + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? tableType(rs.getString("tabtype")) : ""; + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private String viewSource(String schema, String name) { + try { + String owner = trim(schema); + List args = new ArrayList<>(); + args.add(name); + StringBuilder sql = new StringBuilder(""" + SELECT v.viewtext + FROM sysviews v + JOIN systables t ON t.tabid = v.tabid + WHERE t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + sql.append(" ORDER BY v.seqno"); + StringBuilder source = new StringBuilder(); + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String chunk = rs.getString("viewtext"); + source.append(chunk == null ? "" : chunk); + } + } + } + String result = stripTrailing(source.toString()); + if (result.isEmpty()) { + throw new IllegalArgumentException("View source not found: " + name); + } + return result; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private boolean isEditableView(String schema, String name) { + try { + String owner = trim(schema); + List args = new ArrayList<>(); + args.add(name); + StringBuilder sql = new StringBuilder(""" + SELECT t.tabid, t.owner, + (SELECT v.tabid FROM systables v WHERE UPPER(TRIM(v.tabname)) = 'VERSION') AS system_boundary_tabid + FROM systables t + WHERE t.tabtype = 'V' AND t.tabname = ? + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + try (PreparedStatement stmt = requireConnection().prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return true; + } + int systemBoundaryTabid = rs.getInt("system_boundary_tabid"); + Integer boundary = rs.wasNull() ? null : systemBoundaryTabid; + return !isSystemCatalogView(rs.getInt("tabid"), rs.getString("owner"), boundary); + } + } + } catch (Exception ignored) { + return true; + } + } + + private static boolean isSystemCatalogView(int tabid, String owner, Integer systemBoundaryTabid) { + if (!"gbasedbt".equalsIgnoreCase(trim(owner))) { + return false; + } + // GBase 8s marks system catalog tables before/at VERSION; avoid a fixed tabid threshold + // so ordinary gbasedbt-owned user views are still editable. + if (systemBoundaryTabid != null && systemBoundaryTabid > 0) { + return tabid <= systemBoundaryTabid; + } + return tabid < 100; + } + + public static String mapColType(int coltype) { + return switch (baseColType(coltype)) { + case 0 -> "CHAR"; + case 1 -> "SMALLINT"; + case 2 -> "INTEGER"; + case 3 -> "FLOAT"; + case 4 -> "SMALLFLOAT"; + case 5 -> "DECIMAL"; + case 6 -> "SERIAL"; + case 7 -> "DATE"; + case 8 -> "MONEY"; + case 9 -> "NULL"; + case 10 -> "DATETIME"; + case 11 -> "BYTE"; + case 12 -> "TEXT"; + case 13 -> "VARCHAR"; + case 14 -> "INTERVAL"; + case 15 -> "NCHAR"; + case 16 -> "NVARCHAR"; + case 17 -> "INT8"; + case 18 -> "SERIAL8"; + case 19 -> "SET"; + case 20 -> "MULTISET"; + case 21 -> "LIST"; + case 22 -> "ROW"; + case 23 -> "COLLECTION"; + case 40 -> "LVARCHAR"; + case 41 -> "BOOLEAN"; + case 43, 52 -> "BIGINT"; + case 44, 53 -> "BIGSERIAL"; + default -> "UNKNOWN(" + baseColType(coltype) + ")"; + }; + } + + public static Set primaryKeyColumnNumbers(List parts) { + Set result = new HashSet<>(); + for (Integer part : parts) { + if (part == null) { + continue; + } + int value = Math.abs(part); + if (value > 0) { + result.add(value); + } + } + return result; + } + + private Set getPrimaryKeyColumnNumbers(Connection conn, String owner, String table) throws Exception { + List args = new ArrayList<>(); + args.add(table); + StringBuilder sql = new StringBuilder(""" + SELECT i.part1, i.part2, i.part3, i.part4, i.part5, i.part6, i.part7, i.part8, + i.part9, i.part10, i.part11, i.part12, i.part13, i.part14, i.part15, i.part16 + FROM sysconstraints c + JOIN sysindexes i ON i.idxname = c.idxname AND i.tabid = c.tabid + JOIN systables t ON t.tabid = c.tabid + WHERE t.tabname = ? AND c.constrtype = 'P' + """.stripIndent().trim()); + if (!owner.isEmpty()) { + sql.append(" AND t.owner = ?"); + args.add(owner); + } + + try (PreparedStatement stmt = conn.prepareStatement(sql.toString())) { + bind(stmt, args); + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return Collections.emptySet(); + } + List parts = new ArrayList<>(); + for (int index = 1; index <= 16; index += 1) { + int value = rs.getInt(index); + parts.add(rs.wasNull() ? null : value); + } + return primaryKeyColumnNumbers(parts); + } + } + } + + private static int baseColType(int coltype) { + return coltype % 256; + } + + private static Integer numericPrecision(int baseType, int length) { + if (baseType == 5 || baseType == 8) { + return (length >> 8) & 0xff; + } + return null; + } + + private static Integer numericScale(int baseType, int length) { + if (baseType == 5 || baseType == 8) { + return length & 0xff; + } + return null; + } + + private static Integer characterMaximumLength(int baseType, int length) { + return switch (baseType) { + case 0, 13, 15, 16, 40 -> length; + default -> null; + }; + } + private static void appendGbase8sTableTypePredicate(StringBuilder sql, MetadataListConstraints constraints) { if (!constraints.hasObjectTypes()) { sql.append(" AND tabtype IN ('T', 'V')"); @@ -372,6 +647,14 @@ public final class Gbase8sAgent extends ConfiguredJdbcAgent { return value == null ? "" : value.trim(); } + private static String emptyToNull(String value) { + return value.isEmpty() ? null : value; + } + + private static String stripTrailing(String value) { + return value == null ? "" : value.stripTrailing(); + } + private String currentCatalog() { try { return trim(requireConnection().getCatalog()); diff --git a/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java b/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java index 726873c4a..d30bc4a49 100644 --- a/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java +++ b/agents/drivers/gbase8s/src/test/java/com/dbx/agent/gbase8s/Gbase8sAgentTest.java @@ -1,7 +1,9 @@ package com.dbx.agent.gbase8s; import com.dbx.agent.ConnectParams; +import com.dbx.agent.ColumnInfo; import com.dbx.agent.MetadataListConstraints; +import com.dbx.agent.ObjectSource; import com.dbx.agent.TableInfo; import com.dbx.agent.test.TestSupport; import org.junit.jupiter.api.Assertions; @@ -14,7 +16,9 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Set; class Gbase8sAgentTest { @Test @@ -131,10 +135,149 @@ class Gbase8sAgentTest { Assertions.assertTrue(sql.get(0).contains("UPPER(tabname) LIKE ?"), sql.get(0)); } - private static Connection preparedConnection(List sql, ResultSet resultSet) { + @Test + void extractsPrimaryKeyColumnNumbersFromGbase8sIndexParts() { + Assertions.assertEquals( + Set.of(1, 3, 5), + Gbase8sAgent.primaryKeyColumnNumbers(Arrays.asList(1, -3, 0, 5, null)) + ); + } + + @Test + void getColumnsUsesGbase8sSystemCatalog() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + resultSet( + new String[]{"part1", "part2", "part3", "part4", "part5", "part6", "part7", "part8", "part9", "part10", "part11", "part12", "part13", "part14", "part15", "part16"}, + new Object[][]{ + {1, 0, null, null, null, null, null, null, null, null, null, null, null, null, null, null} + } + ), + resultSet( + new String[]{"colname", "coltype", "colno", "collength"}, + new Object[][]{ + {"product_id", 258, 1, 4}, + {"sku", 13, 2, 40}, + {"price", 5, 3, 3074} + } + ) + )); + + List columns = agent.getColumns("root", "products"); + + Assertions.assertEquals(2, sql.size()); + Assertions.assertTrue(sql.get(0).contains("FROM sysconstraints"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("t.owner = ?"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("FROM syscolumns"), sql.get(1)); + Assertions.assertTrue(sql.get(1).contains("t.owner = ?"), sql.get(1)); + Assertions.assertEquals(3, columns.size()); + Assertions.assertEquals("product_id", columns.get(0).getName()); + Assertions.assertEquals("INTEGER", columns.get(0).getData_type()); + Assertions.assertFalse(columns.get(0).getIs_nullable()); + Assertions.assertTrue(columns.get(0).getIs_primary_key()); + Assertions.assertEquals("VARCHAR", columns.get(1).getData_type()); + Assertions.assertEquals(40, columns.get(1).getCharacter_maximum_length()); + Assertions.assertEquals("DECIMAL", columns.get(2).getData_type()); + Assertions.assertEquals(12, columns.get(2).getNumeric_precision()); + Assertions.assertEquals(2, columns.get(2).getNumeric_scale()); + } + + @Test + void getObjectSourceUsesGbase8sViewCatalog() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + resultSet( + new String[]{"viewtext"}, + new Object[][]{ + {"create view \"gbasedbt\".demo_view as select "}, + {"* from products; "} + } + ), + resultSet( + new String[]{"tabid", "owner", "system_boundary_tabid"}, + new Object[][]{ + {1000, "gbasedbt", 614} + } + ) + )); + + ObjectSource source = agent.getObjectSource("gbasedbt", "demo_view", "VIEW"); + + Assertions.assertEquals("demo_view", source.getName()); + Assertions.assertEquals("VIEW", source.getObject_type()); + Assertions.assertEquals("gbasedbt", source.getSchema()); + Assertions.assertEquals("create view \"gbasedbt\".demo_view as select * from products;", source.getSource()); + Assertions.assertTrue(source.isEditable()); + Assertions.assertEquals(2, sql.size()); + Assertions.assertTrue(sql.get(0).contains("FROM sysviews"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("t.owner = ?"), sql.get(0)); + Assertions.assertTrue(sql.get(0).contains("ORDER BY v.seqno"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("system_boundary_tabid"), sql.get(1)); + } + + @Test + void getObjectSourceMarksGbase8sSystemViewsReadOnly() { + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + new ArrayList<>(), + resultSet( + new String[]{"viewtext"}, + new Object[][]{ + {"create view \"gbasedbt\".dba_db_links as select * from user_db_links;"} + } + ), + resultSet( + new String[]{"tabid", "owner", "system_boundary_tabid"}, + new Object[][]{ + {614, "gbasedbt", 614} + } + ) + )); + + ObjectSource source = agent.getObjectSource("gbasedbt", "dba_db_links", "VIEW"); + + Assertions.assertFalse(source.isEditable()); + } + + @Test + void getTableDdlReturnsViewSourceForGbase8sViews() { + List sql = new ArrayList<>(); + Gbase8sAgent agent = new Gbase8sAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection( + sql, + resultSet( + new String[]{"tabtype"}, + new Object[][]{ + {"V"} + } + ), + resultSet( + new String[]{"viewtext"}, + new Object[][]{ + {"create view demo_view as select 1 as id; "} + } + ) + )); + + String ddl = agent.getTableDdl("gbasedbt", "demo_view"); + + Assertions.assertEquals("create view demo_view as select 1 as id;", ddl); + Assertions.assertEquals(2, sql.size()); + Assertions.assertTrue(sql.get(0).contains("SELECT tabtype FROM systables"), sql.get(0)); + Assertions.assertTrue(sql.get(1).contains("FROM sysviews"), sql.get(1)); + } + + private static Connection preparedConnection(List sql, ResultSet... resultSets) { + int[] resultIndex = {0}; PreparedStatement statement = proxy(PreparedStatement.class, (method, args) -> { if ("executeQuery".equals(method.getName())) { - return resultSet; + int index = Math.min(resultIndex[0], resultSets.length - 1); + resultIndex[0] += 1; + return resultSets[index]; } if ("setString".equals(method.getName()) || "close".equals(method.getName())) { return null; @@ -158,6 +301,7 @@ class Gbase8sAgentTest { private static ResultSet resultSet(String[] columns, Object[][] rows) { int[] index = {-1}; + Object[] lastValue = {null}; return proxy(ResultSet.class, (method, args) -> { switch (method.getName()) { case "next": @@ -165,7 +309,20 @@ class Gbase8sAgentTest { return index[0] < rows.length; case "getString": Object value = columnValue(columns, rows[index[0]], args[0]); + lastValue[0] = value; return value == null ? null : String.valueOf(value); + case "getInt": + Object intValue = columnValue(columns, rows[index[0]], args[0]); + lastValue[0] = intValue; + if (intValue == null) { + return 0; + } + if (intValue instanceof Number) { + return ((Number) intValue).intValue(); + } + return Integer.parseInt(String.valueOf(intValue)); + case "wasNull": + return lastValue[0] == null; case "close": return null; default: diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 718cce840..a9c798170 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -38,7 +38,7 @@ import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresenta import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/sql/newQueryContext"; -import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/table/objectSourceEditor"; +import { buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor"; import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget"; import { uuid } from "@/lib/common/utils"; import { isMacOS } from "@/lib/backend/platform"; @@ -762,20 +762,15 @@ async function saveActiveObjectSource(tab: QueryTab): Promise { if (!connection || !source) return false; try { + const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection.db_type; const statements = await buildExecutableObjectSourceStatements({ - databaseType: connection.db_type, + databaseType, objectType: source.objectType, schema: source.schema || tab.schema || tab.database, name: source.name, source: tab.sql, }); - for (const sql of statements) { - if (objectSourceSaveExecutionMode(connection.db_type) === "single") { - await api.executeQuery(tab.connectionId, tab.database, sql, source.schema || tab.schema); - } else { - await api.executeScript(tab.connectionId, tab.database, sql, source.schema || tab.schema); - } - } + await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema); queryStore.markTabClean(tab); toast(t("objects.sourceSaved"), 2000); return true; diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index a072d711e..d7ea04db5 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -77,6 +77,7 @@ const props = defineProps<{ executionErrorSql?: string; readOnly?: boolean; forceWordWrap?: boolean; + hideExecutionControls?: boolean; initialViewport?: { scrollTop: number; scrollLeft: number }; initialSelection?: { anchor: number; head: number }; }>(); @@ -794,13 +795,17 @@ function selectSqlLineFromGutter(currentView: EditorViewType, line: { from: numb const contextMenuItems = computed(() => { const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); return [ - { - label: executeContextMenuLabel.value, - action: executeFromContextMenu, - disabled: !canExecuteContextSql.value, - icon: Play, - shortcut: shortcuts.executeSql, - }, + ...(props.hideExecutionControls + ? [] + : [ + { + label: executeContextMenuLabel.value, + action: executeFromContextMenu, + disabled: !canExecuteContextSql.value, + icon: Play, + shortcut: shortcuts.executeSql, + }, + ]), { label: t("contextMenu.viewData"), action: openTableFromContextMenu, @@ -849,6 +854,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); const Prec = codeMirrorPrec; const binding = (shortcut: string, run: (view: EditorViewType) => boolean) => (shortcut ? [{ key: shortcutToCodeMirrorKey(shortcut), preventDefault: true, run }] : []); + const executeBindings = props.hideExecutionControls ? [] : binding(shortcuts.executeSql, () => requestExecute({ forceCurrent: true })); return [ Prec?.high( codeMirrorKeymap.of([ @@ -859,7 +865,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") }, ...binding(shortcuts.find, openSearch), ...binding(shortcuts.replace, openReplace), - ...binding(shortcuts.executeSql, () => requestExecute({ forceCurrent: true })), + ...executeBindings, ...binding(shortcuts.saveSql, () => { emit("save"); return true; @@ -2643,7 +2649,7 @@ onMounted(async () => { return { dom }; }, }), - runGutterComp.of(buildRunStatementGutterExtension()), + runGutterComp.of(props.hideExecutionControls ? [] : buildRunStatementGutterExtension()), lineNumbers({ domEventHandlers: { mousedown: selectSqlLineFromGutter, @@ -3042,7 +3048,7 @@ watch( codeMirrorTheme.reconfigure(themeExt), wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), vimModeComp.reconfigure(vimModeExtension(settingsStore.editorSettings.vimModeEnabled)), - runGutterComp.reconfigure(buildRunStatementGutterExtension?.() ?? []), + runGutterComp.reconfigure(props.hideExecutionControls ? [] : (buildRunStatementGutterExtension?.() ?? [])), runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)), ], }); diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index 73993b40d..ac25a17c9 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue"; import type { EditorView } from "@codemirror/view"; +import type { ObjectSourceKind } from "@/types/database"; const props = withDefaults( defineProps<{ @@ -22,6 +23,7 @@ const props = withDefaults( database: string; schema?: string; tableName: string; + objectType?: ObjectSourceKind; /** SQL dialect for syntax highlighting. Non-PG/non-MSSQL databases fall back to MySQL (same as QueryEditor's source viewer). */ dialect: "mysql" | "postgres" | "sqlserver"; /** SQL formatter dialect. Kept separate from the syntax-highlighting dialect because several PG-compatible DBs highlight as MySQL. */ @@ -56,7 +58,7 @@ watch( ddlLoading.value = true; try { const schema = props.schema || props.database; - const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName); + const ddl = await api.getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType); ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); } catch (e: any) { ddlError.value = e?.message || String(e); @@ -171,7 +173,7 @@ function retry() { ddlContent.value = ""; const schema = props.schema || props.database; api - .getTableDdl(props.connectionId, props.database, schema, props.tableName) + .getTableDdl(props.connectionId, props.database, schema, props.tableName, props.objectType) .then(async (ddl) => { ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); }) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index b79041089..acb36b5ee 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -54,9 +54,8 @@ import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDa import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; import { buildDropObjectSql, buildDropTableSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, supportsDropTableCascade, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import { useToast } from "@/composables/useToast"; -import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, objectSourceSaveExecutionMode, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; +import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql"; -import { buildViewDdl } from "@/lib/table/viewDdl"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { generateDatabaseExportId } from "@/lib/export/databaseExport"; import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard"; @@ -123,6 +122,7 @@ const sourceContent = ref(""); const sourceError = ref(""); const sourceRow = ref(null); const sourceEditing = ref(false); +const sourceCanEdit = ref(true); const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type); const tableStructureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(props.connection) ?? props.connection.db_type); const sourceEditableText = ref(""); @@ -426,12 +426,14 @@ async function openSource(row: ObjectBrowserRow) { sourceContent.value = ""; sourceError.value = ""; sourceEditing.value = false; + sourceCanEdit.value = true; sourceEditableText.value = ""; sourceDraft.value = ""; sourceSaveError.value = ""; sourceLoading.value = true; try { const result = await api.getObjectSource(props.connection.id, props.database, row.schema || selectedSchema.value || props.database, row.name, row.type as ObjectSourceKind); + sourceCanEdit.value = result.editable !== false && row.type !== "SEQUENCE"; const editable = await api.buildEditableObjectSource({ databaseType: effectiveDatabaseType.value, objectType: row.type as ObjectSourceKind, @@ -442,7 +444,10 @@ async function openSource(row: ObjectBrowserRow) { sourceEditableText.value = editable; sourceContent.value = await formatSqlForDisplay(editable, sourceFormatDialect.value, settingsStore.editorSettings.sqlFormatter); sourceDraft.value = editable; - sourceEditing.value = row.type !== "SEQUENCE"; + sourceEditing.value = sourceCanEdit.value; + if (!sourceCanEdit.value && row.type !== "SEQUENCE") { + toast(t("objects.sourceReadOnly"), 3000); + } } catch (e: any) { sourceError.value = e?.message || String(e); } finally { @@ -450,27 +455,6 @@ async function openSource(row: ObjectBrowserRow) { } } -async function openViewDdl(row: ObjectBrowserRow) { - if (row.type !== "VIEW" && row.type !== "MATERIALIZED_VIEW") return; - try { - const schema = row.schema || selectedSchema.value || props.database; - const ddl = - row.type === "MATERIALIZED_VIEW" - ? await api.getTableDdl(props.connection.id, props.database, schema, row.name, "MATERIALIZED_VIEW") - : await buildViewDdl({ - databaseType: effectiveDatabaseType.value, - schema, - name: row.name, - source: (await api.getObjectSource(props.connection.id, props.database, schema, row.name, "VIEW")).source, - }); - const formatted = await formatSqlForDisplay(ddl, sourceFormatDialect.value, settingsStore.editorSettings.sqlFormatter); - const tabId = queryStore.createTab(props.connection.id, props.database, `DDL - ${row.name}`); - queryStore.updateSql(tabId, formatted); - } catch (e: any) { - toast(e?.message || String(e), 5000); - } -} - async function openNewQuery(row: ObjectBrowserRow) { const tabId = queryStore.createTab(props.connection.id, props.database, row.name); queryStore.updateSql( @@ -1219,6 +1203,10 @@ async function copySource() { function editSource() { if (!sourceRow.value || !sourceEditableText.value) return; + if (!sourceCanEdit.value) { + toast(t("objects.sourceReadOnly"), 3000); + return; + } sourceDraft.value = sourceEditableText.value; sourceSaveError.value = ""; sourceEditing.value = true; @@ -1231,6 +1219,10 @@ function cancelEditSource() { } async function saveSource() { + if (!sourceCanEdit.value) { + toast(t("objects.sourceReadOnly"), 3000); + return; + } if (!sourceRow.value || !sourceDraft.value.trim()) return; const row = sourceRow.value; const schema = row.schema || selectedSchema.value || props.database; @@ -1244,13 +1236,7 @@ async function saveSource() { name: row.name, source: sourceDraft.value, }); - for (const sql of statements) { - if (objectSourceSaveExecutionMode(effectiveDatabaseType.value) === "single") { - await api.executeQuery(props.connection.id, props.database, sql, schema); - } else { - await api.executeScript(props.connection.id, props.database, sql, schema); - } - } + await executeObjectSourceSave(props.connection.id, props.database, effectiveDatabaseType.value, statements, schema); toast(t("objects.sourceSaved")); sourceEditing.value = false; sourceDraft.value = ""; @@ -1502,7 +1488,14 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { { label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 }, { label: t("contextMenu.editView"), action: () => openSource(item), icon: PencilLine }, { label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 }, - { label: t("contextMenu.viewDdl"), action: () => openViewDdl(item), icon: ScrollText }, + { + label: t("contextMenu.viewDdl"), + action: () => { + ddlDialogTarget.value = item; + showDdlDialog.value = true; + }, + icon: ScrollText, + }, ...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []), { label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare }, ...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []), @@ -1805,7 +1798,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { -