From a6f4cebd182662d8f08c0671c333300c4cd5b26a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sat, 22 Aug 2026 13:40:42 +0800 Subject: [PATCH] feat(editor): include database name in generated SQL --- apps/desktop/src/App.vue | 1 + .../editor/EditorSettingsDialog.vue | 15 ++ apps/desktop/src/components/grid/DataGrid.vue | 29 +++- .../src/components/objects/ObjectBrowser.vue | 1 + .../sidebar/SidebarTreeRuntimeHost.vue | 5 + .../src/composables/useDataGridActions.ts | 1 + .../src/composables/useNavigationTargets.ts | 4 + .../composables/useSidebarDataOpenRuntime.ts | 1 + apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../dataGrid/dataGridContextMenuState.spec.ts | 2 +- .../lib/__tests__/sql/newQueryContext.spec.ts | 4 + .../__tests__/table/tableSelectSql.spec.ts | 87 ++++++++++- apps/desktop/src/lib/backend/http.ts | 2 +- apps/desktop/src/lib/backend/tauri.ts | 2 +- .../settings/__tests__/settingsSearch.spec.ts | 1 + .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsSearch.ts | 1 + apps/desktop/src/lib/sql/newQueryContext.ts | 7 +- apps/desktop/src/lib/table/tableSelectSql.ts | 137 +++++++++++++++++- .../src/lib/table/tableSqlTemplates.ts | 2 + apps/desktop/src/stores/queryStore.ts | 1 + apps/desktop/src/stores/settingsStore.ts | 4 + crates/dbx-core/src/sql_dialect.rs | 3 +- .../dbx-core/src/sql_dialect/table_select.rs | 55 +++++++ crates/dbx-web/src/routes/query.rs | 4 +- packages/app-tests/queryResultToolbar.test.ts | 6 +- src-tauri/src/commands/query.rs | 10 +- 34 files changed, 379 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 73ded6da14..c43010ee7e 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -1705,6 +1705,7 @@ async function newQuery() { databaseType: effectiveDatabaseTypeForConnection(conn), driverProfile: conn.driver_profile, identifierQuote: connectionStore.connectionIdentifierQuote?.(target.connectionId), + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, }); const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema, initialSql, target.catalog); if (initialSql) { diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 6ebbd0c062..bbec9cf0ec 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -444,6 +444,7 @@ const editDisconnectTabHandlingMode = ref(settingsSto const editDataTabReuseMode = ref(settingsStore.editorSettings.dataTabReuseMode); const editOpenDataTabsNextToActive = ref(settingsStore.editorSettings.openDataTabsNextToActive); const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect); +const editGenerateSqlIncludeDatabaseName = ref(settingsStore.editorSettings.generateSqlIncludeDatabaseName); const editClickTableNavigationTarget = ref(settingsStore.editorSettings.clickTableNavigationTarget); const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled); const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n")); @@ -573,6 +574,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { dataTabReuseMode: editDataTabReuseMode.value, openDataTabsNextToActive: editOpenDataTabsNextToActive.value, prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value, + generateSqlIncludeDatabaseName: editGenerateSqlIncludeDatabaseName.value, updateNotificationsEnabled: editUpdateNotificationsEnabled.value, sidebarObjectInfoMode: editSidebarObjectInfoMode.value, sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value, @@ -860,6 +862,7 @@ function syncEditorSettingsDraftFromStore() { editDataTabReuseMode.value = settingsStore.editorSettings.dataTabReuseMode; editOpenDataTabsNextToActive.value = settingsStore.editorSettings.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect; + editGenerateSqlIncludeDatabaseName.value = settingsStore.editorSettings.generateSqlIncludeDatabaseName; editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled; editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"); @@ -1107,6 +1110,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode; editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; + editGenerateSqlIncludeDatabaseName.value = DEFAULT_EDITOR_SETTINGS.generateSqlIncludeDatabaseName; editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode; @@ -1229,6 +1233,7 @@ function resetAllDefaults() { editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode; editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; + editGenerateSqlIncludeDatabaseName.value = DEFAULT_EDITOR_SETTINGS.generateSqlIncludeDatabaseName; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode; editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll; @@ -4094,6 +4099,16 @@ onUnmounted(() => { + +
+
+ +

+ {{ t("settings.generateSqlIncludeDatabaseNameDescription") }} +

+
+ +
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index fb8b1c022a..07e757f6a0 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -83,7 +83,7 @@ import { formatElapsedSeconds } from "@/lib/common/elapsedTime"; import { dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion"; import { createColumnDrafts } from "@/lib/table/tableStructureEditorState"; import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructureEditorSql"; -import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; +import { buildTableSelectSql, qualifyTableReferencesInSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { uuid } from "@/lib/common/utils"; import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration"; import { compactHeaderColumnType, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType"; @@ -4931,6 +4931,7 @@ async function fetchLargeValueRequestChunk(columnIndex: number, requests: LargeV tableType: tableMeta.tableType, catalog: tableMeta.catalog, columns: selectedColumns, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, primaryKeys: tableMeta.primaryKeys, whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "), limit: requests.length, @@ -6214,6 +6215,7 @@ async function applyOrderBySearch() { tableName: tableMeta.tableName, tableType: tableMeta.tableType, columns: tableMeta.columns.map((column) => column.name), + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, primaryKeys: tableMeta.primaryKeys, ...tableDataLargeValuePreviewOptions(resolvedDatabaseType.value, tableMeta.columns, tableMeta.primaryKeys, pageSize.value), orderBy: orderByClause, @@ -6251,6 +6253,7 @@ async function applyWhereFilter() { tableName: tableMeta.tableName, tableType: tableMeta.tableType, columns: tableMeta.columns.map((column) => column.name), + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, primaryKeys: tableMeta.primaryKeys, ...tableDataLargeValuePreviewOptions(resolvedDatabaseType.value, tableMeta.columns, tableMeta.primaryKeys, pageSize.value), orderBy: orderByInput.value.trim() || (sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined), @@ -7331,11 +7334,23 @@ async function cancelActiveExport() { const userFacingSql = ref(""); let userFacingSqlGeneration = 0; +function sqlWithDisplayDatabaseName(sql: string): string { + const database = props.tableMeta?.database ?? props.database; + if (!settingsStore.editorSettings.generateSqlIncludeDatabaseName || !database) return sql; + return qualifyTableReferencesInSql(sql, { + databaseType: resolvedDatabaseType.value, + database, + includeDatabaseName: true, + }); +} + async function syncUserFacingSql() { const generation = ++userFacingSqlGeneration; const executionSql = props.sql?.trim() ?? ""; - if (props.context !== "table-data" || !executionSql.includes("__DBX_LARGE_VALUE_BYTES_") || !props.tableMeta?.tableName) { - userFacingSql.value = executionSql; + const includeDatabaseName = settingsStore.editorSettings.generateSqlIncludeDatabaseName; + const shouldRebuildSql = executionSql.includes("__DBX_LARGE_VALUE_BYTES_") || includeDatabaseName; + if (props.context !== "table-data" || !shouldRebuildSql || !props.tableMeta?.tableName) { + userFacingSql.value = sqlWithDisplayDatabaseName(executionSql); return; } @@ -7350,19 +7365,20 @@ async function syncUserFacingSql() { schema: props.tableMeta.schema, tableName: props.tableMeta.tableName, tableType: props.tableMeta.tableType, + includeDatabaseName, whereInput: currentWhereInput(), orderBy: currentOrderBy(), limit: props.pageLimit ?? pageSize.value, offset: props.pageOffset ?? Math.max(0, currentPage.value - 1) * pageSize.value, }); - if (generation === userFacingSqlGeneration) userFacingSql.value = sql; + if (generation === userFacingSqlGeneration) userFacingSql.value = sqlWithDisplayDatabaseName(sql); } catch { - if (generation === userFacingSqlGeneration) userFacingSql.value = executionSql; + if (generation === userFacingSqlGeneration) userFacingSql.value = sqlWithDisplayDatabaseName(executionSql); } } watch( - () => [props.sql, props.context, props.tableMeta, props.pageLimit, props.pageOffset, currentWhereInput(), currentOrderBy()], + () => [props.sql, props.context, props.tableMeta, props.pageLimit, props.pageOffset, currentWhereInput(), currentOrderBy(), settingsStore.editorSettings.generateSqlIncludeDatabaseName], () => void syncUserFacingSql(), { immediate: true }, ); @@ -10380,6 +10396,7 @@ async function loadForeignKeyDisplayLabels() { schema, tableName: config.refTable, columns: [config.refColumn, config.displayColumn], + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, whereInput, limit: batch.length, }); diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index f8119ea24c..af7435bbe6 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -1313,6 +1313,7 @@ async function openNewQuery(row: ObjectBrowserRow) { database: props.database, schema, tableName: row.name, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, limit: 100, }), ); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 6a3c0e8b77..66791c53b6 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1656,6 +1656,7 @@ async function newQuery() { catalog: node.catalog, database: node.database, schema: node.schema, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableName: node.label, columns: [], }); @@ -1738,6 +1739,7 @@ async function newSelectTemplate() { catalog: context.node.catalog, database: context.node.database, schema: context.tableSchema, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableName: context.node.label, columns: context.columns, }); @@ -1758,6 +1760,7 @@ async function newInsertTemplate() { catalog: context.node.catalog, database: context.node.database, schema: context.tableSchema, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableName: context.node.label, columns: context.columns, tableType: context.tableType, @@ -1779,6 +1782,7 @@ async function newUpdateTemplate() { catalog: context.node.catalog, database: context.node.database, schema: context.tableSchema, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableName: context.node.label, columns: context.columns, }); @@ -1799,6 +1803,7 @@ async function newDeleteTemplate() { catalog: context.node.catalog, database: context.node.database, schema: context.tableSchema, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableName: context.node.label, columns: context.columns, }); diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 8ab3145dcc..4299a1bc34 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -80,6 +80,7 @@ export function useDataGridActions(activeTab: ComputedRef) columns: realColumns?.map((column) => column.name), primaryKeys, ...tableDataLargeValuePreviewOptions(effectiveDbType, realColumns ?? [], primaryKeys, limit), + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, includeRowId: useRowId, limit, ...options, diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 1865acd7a8..c4461f60e8 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -118,6 +118,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab catalog: target.catalog, database: target.database, tableName: target.tableName, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableType: targetTableType, columns: columns.map((column) => column.name), primaryKeys, @@ -162,6 +163,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab catalog: target.catalog, database: target.database, tableName: target.tableName, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableType: targetTableType, columns: eagerColumns.map((column) => column.name), primaryKeys: eagerPrimaryKeys, @@ -209,6 +211,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab catalog: target.catalog, database: target.database, tableName: target.tableName, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableType: targetTableType, whereInput: target.whereInput, limit: 0, @@ -254,6 +257,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab catalog: target.catalog, database: target.database, tableName: target.tableName, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, tableType: targetTableType, whereInput: target.whereInput, primaryKeys, diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 60676546c6..e94eaa34a7 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -347,6 +347,7 @@ export function useSidebarDataOpenRuntime() { columns: columns.map((column) => column.name), primaryKeys, ...tableDataLargeValuePreviewOptions(effectiveDbType, columns, primaryKeys, limit), + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, limit, includeRowId, }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2d41f878ef..74bbf1c489 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6320,6 +6320,8 @@ export default { savedSqlOpenTargetCurrentDescription: "Use the active tab’s connection, database, schema, and catalog when available; otherwise use the saved target.", prefillNewQueryWithSelect: "Prefill new query with SELECT *", prefillNewQueryWithSelectDescription: "When creating a new query, prefill the editor with SELECT * FROM based on the active table tab or the table selected in the sidebar.", + generateSqlIncludeDatabaseName: "Include database name in generated SQL", + generateSqlIncludeDatabaseNameDescription: "When supported by the database, generated table SQL uses database.table instead of only the table name.", clickTableNavigationTarget: "Ctrl+Click Table Opens DDL", clickTableNavigationTargetDescription: "When enabled, Ctrl/Cmd+clicking a table name opens the table structure editor (DDL). When disabled, it opens the table data view.", sqlVariableSyntax: "SQL variable & placeholder substitution", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6733789872..24e2c12bdd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6009,6 +6009,8 @@ export default withEnglishFallback({ savedSqlOpenTargetCurrentDescription: "Usar la conexión, la base de datos, el esquema y el catálogo de la pestaña activa cuando estén disponibles; de lo contrario, usar el destino guardado.", prefillNewQueryWithSelect: "Rellenar nueva consulta con SELECT *", prefillNewQueryWithSelectDescription: "Al crear una nueva consulta, rellena el editor con SELECT * FROM según la pestaña de tabla activa o la tabla seleccionada en la barra lateral.", + generateSqlIncludeDatabaseName: "Incluir el nombre de la base de datos en el SQL generado", + generateSqlIncludeDatabaseNameDescription: "Cuando la base de datos lo admite, el SQL de tabla generado usa base_de_datos.tabla en lugar de solo el nombre de la tabla.", clickTableNavigationTarget: "Ctrl+Clic abre DDL de tabla", clickTableNavigationTargetDescription: "Cuando está activado, Ctrl/Cmd+clic en el nombre de la tabla abre el editor de estructura (DDL). Cuando está desactivado, abre la vista de datos.", sqlVariableSyntax: "Sustitución de variables y marcadores SQL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index e0c4750665..30417cb450 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6009,6 +6009,8 @@ export default withEnglishFallback({ savedSqlOpenTargetCurrentDescription: "Usa la connessione, il database, lo schema e il catalogo della scheda attiva quando disponibili; altrimenti usa la destinazione salvata.", prefillNewQueryWithSelect: "Precompila nuova query con SELECT *", prefillNewQueryWithSelectDescription: "Alla creazione di una nuova query, precompila l'editor con SELECT * FROM in base alla scheda tabella attiva o alla tabella selezionata nella barra laterale.", + generateSqlIncludeDatabaseName: "Includi il nome del database nell'SQL generato", + generateSqlIncludeDatabaseNameDescription: "Quando supportato dal database, l'SQL della tabella generato usa database.tabella anziché solo il nome della tabella.", clickTableNavigationTarget: "Ctrl+Clic apre DDL tabella", clickTableNavigationTargetDescription: "Quando attivato, Ctrl/Cmd+clic sul nome della tabella apre l'editor di struttura (DDL). Quando disattivato, apre la vista dati.", sqlVariableSyntax: "Sostituzione di variabili e segnaposto SQL", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 59f570a286..c952c6f78b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6031,6 +6031,8 @@ export default withEnglishFallback({ savedSqlOpenTargetCurrentDescription: "利用可能な場合は現在のタブの接続、データベース、スキーマ、カタログを使用し、それ以外は保存時の実行先を使用します。", prefillNewQueryWithSelect: "新規クエリに SELECT を自動入力", prefillNewQueryWithSelectDescription: "新規クエリ作成時、アクティブなテーブルタブまたはサイドバーで選択したテーブルに基づき、エディタに SELECT * FROM <テーブル名> を自動入力します。", + generateSqlIncludeDatabaseName: "生成 SQL にデータベース名を含める", + generateSqlIncludeDatabaseNameDescription: "データベースが対応している場合、生成するテーブル SQL でテーブル名のみではなく database.table を使用します。", clickTableNavigationTarget: "Ctrl+クリックでテーブル構造を開く", clickTableNavigationTargetDescription: "有効にすると、Ctrl/Cmd+クリックでテーブル構造エディタ(DDL)を開きます。無効の場合はテーブルデータビューを開きます。", sqlVariableSyntax: "SQL 変数・プレースホルダー置換", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 2852838a86..ed39966346 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5737,6 +5737,8 @@ export default withEnglishFallback({ savedSqlOpenTargetCurrentDescription: "가능한 경우 현재 탭의 연결, 데이터베이스, 스키마 및 카탈로그를 사용하고, 그렇지 않으면 저장된 대상을 사용합니다.", prefillNewQueryWithSelect: "새 쿼리에 SELECT * 미리 채우기", prefillNewQueryWithSelectDescription: "새 쿼리를 만들 때 활성 테이블 탭이나 사이드바에서 선택한 테이블을 기반으로 편집기에 SELECT * FROM
을 미리 채웁니다.", + generateSqlIncludeDatabaseName: "생성된 SQL에 데이터베이스 이름 포함", + generateSqlIncludeDatabaseNameDescription: "데이터베이스가 지원하는 경우 생성된 테이블 SQL에서 테이블 이름만이 아니라 database.table을 사용합니다.", sqlVariableSyntax: "SQL 변수 및 자리표시자 치환", sqlVariableSyntaxDescription: "데이터베이스 유형별로 SQL을 실행하기 전에 DBX가 치환할 변수와 자리표시자 문법을 선택하세요. 모두 기본적으로 활성화됩니다.", sqlVariableSubstitutionEnabled: "치환 사용", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f76af49764..530d3ef07b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6011,6 +6011,8 @@ export default withEnglishFallback({ savedSqlOpenTargetCurrentDescription: "Usar a conexão, o banco de dados, o esquema e o catálogo da aba ativa quando disponíveis; caso contrário, usar o destino salvo.", prefillNewQueryWithSelect: "Preencher nova consulta com SELECT *", prefillNewQueryWithSelectDescription: "Ao criar uma nova consulta, preenche o editor com SELECT * FROM com base na aba de tabela ativa ou na tabela selecionada na barra lateral.", + generateSqlIncludeDatabaseName: "Incluir nome do banco de dados no SQL gerado", + generateSqlIncludeDatabaseNameDescription: "Quando compatível com o banco de dados, o SQL de tabela gerado usa banco.tabela em vez de somente o nome da tabela.", clickTableNavigationTarget: "Ctrl+Clique abre DDL da tabela", clickTableNavigationTargetDescription: "Quando ativado, Ctrl/Cmd+clicar no nome da tabela abre o editor de estrutura (DDL). Quando desativado, abre a visualização de dados.", sqlVariableSyntax: "Substituição de variáveis e espaços reservados SQL", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4f0cbbf40f..f801488ecc 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6305,6 +6305,8 @@ export default withEnglishFallback({ savedSqlOpenTargetCurrentDescription: "优先使用当前标签页的连接、数据库、Schema 和 Catalog;不可用时回退到保存时的目标。", prefillNewQueryWithSelect: "新建查询时预填充 SELECT 语句", prefillNewQueryWithSelectDescription: "新建查询时,根据当前激活的数据表标签页或侧边栏选中的表,自动在编辑器中填充 SELECT * FROM <表名>。", + generateSqlIncludeDatabaseName: "生成 SQL 时包含数据库名", + generateSqlIncludeDatabaseNameDescription: "数据库支持时,生成的表 SQL 将使用“数据库名.表名”,而不只包含表名。", clickTableNavigationTarget: "Ctrl+点击表名打开 DDL", clickTableNavigationTargetDescription: "开启后,Ctrl/Cmd+点击表名将打开表结构编辑器(DDL);关闭则打开表数据视图。", sqlVariableSyntax: "SQL 变量与占位符替换", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3408f66938..9637056826 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5330,6 +5330,8 @@ export default withEnglishFallback({ savedSqlOpenTargetCurrentDescription: "優先使用目前分頁的連線、資料庫、Schema 和 Catalog;無法使用時回復至儲存時的目標。", prefillNewQueryWithSelect: "新建查詢時預填 SELECT 語句", prefillNewQueryWithSelectDescription: "新建查詢時,根據目前啟用的資料表分頁或側邊欄選取的資料表,自動在編輯器中填入 SELECT * FROM <資料表名稱>。", + generateSqlIncludeDatabaseName: "產生 SQL 時包含資料庫名稱", + generateSqlIncludeDatabaseNameDescription: "資料庫支援時,產生的資料表 SQL 會使用「資料庫名稱.資料表名稱」,而非只包含資料表名稱。", clickTableNavigationTarget: "Ctrl+點擊表名開啟 DDL", clickTableNavigationTargetDescription: "開啟後,Ctrl/Cmd+點擊表名將開啟表結構編輯器(DDL);關閉則開啟表資料檢視。", sqlVariableSyntax: "SQL 變數與佔位符替換", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts index 77768481ac..fd28d8dbac 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenuState.spec.ts @@ -5,7 +5,7 @@ const dataGridSource = readFileSync(new URL("../../../components/grid/DataGrid.v describe("DataGrid context menu state", () => { it("clears the context target whenever the result is replaced", () => { - expect(dataGridSource).toMatch(/watch\(\s*\(\) => props\.result,[\s\S]*?clearRowSelection\(\);\s*invalidateSyntheticContextSelection\(\);[\s\S]*?exitTransaction\(\);/); + expect(dataGridSource).toMatch(/watch\(\s*\(\) => props\.result,[\s\S]*?clearRowSelection\(\);\s*invalidateContextMenuTarget\(\);[\s\S]*?exitTransaction\(\);/); }); it("uses the actual copyable row count in the copy menu", () => { diff --git a/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts b/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts index 1ab507cbee..8c7d8368e1 100644 --- a/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts @@ -200,6 +200,10 @@ describe("buildSelectAllSql", () => { expect(buildSelectAllSql("mysql", { schema: "mydb", tableName: "users" })).toBe("SELECT * FROM `users`"); }); + it("includes the MySQL database when requested", () => { + expect(buildSelectAllSql("mysql", { database: "mydb", tableName: "users" }, undefined, undefined, true)).toBe("SELECT * FROM `mydb`.`users`"); + }); + it("qualifies and quotes a PostgreSQL table with its schema", () => { expect(buildSelectAllSql("postgres", { schema: "public", tableName: "users" })).toBe('SELECT * FROM "public"."users"'); }); diff --git a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts index 2a4e30ebd9..d844d4a6ef 100644 --- a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { qualifiedTableName, quoteTableDataIdentifier, quoteTableIdentifier } from "@/lib/table/tableSelectSql"; +import { qualifiedTableName, qualifyTableReferencesInSql, quoteTableDataIdentifier, quoteTableIdentifier } from "@/lib/table/tableSelectSql"; describe("qualifiedTableName — Doris/StarRocks multi-catalog", () => { it("prefixes external catalog for Doris (no schema)", () => { @@ -28,6 +28,91 @@ describe("qualifiedTableName — Doris/StarRocks multi-catalog", () => { }); }); +describe("qualifiedTableName — optional database qualification", () => { + it("keeps MySQL table references unchanged unless enabled", () => { + expect(qualifiedTableName({ databaseType: "mysql", database: "analytics", tableName: "events" })).toBe("`events`"); + expect(qualifiedTableName({ databaseType: "mysql", database: "analytics", tableName: "events", includeDatabaseName: true })).toBe("`analytics`.`events`"); + }); + + it("uses the dialect's identifier quoting for database-qualified names", () => { + expect(qualifiedTableName({ databaseType: "clickhouse", database: "a`b", tableName: "c`d", includeDatabaseName: true })).toBe("`a``b`.`c``d`"); + }); + + it("uses MySQL-compatible quoting for GoldenDB", () => { + expect(qualifiedTableName({ databaseType: "goldendb", database: "a`b", tableName: "c`d", includeDatabaseName: true })).toBe("`a``b`.`c``d`"); + }); +}); + +describe("qualifyTableReferencesInSql", () => { + it("qualifies FROM and JOIN sources while preserving aliases", () => { + expect( + qualifyTableReferencesInSql("SELECT * FROM apis AS ap JOIN users AS u ON u.id = ap.user_id", { + databaseType: "mysql", + database: "aaa", + includeDatabaseName: true, + }), + ).toBe("SELECT * FROM `aaa`.`apis` AS ap JOIN `aaa`.`users` AS u ON u.id = ap.user_id"); + }); + + it("qualifies every source in a multi-line LEFT JOIN query", () => { + const sql = "SELECT `u`.`id`, `g`.`group_name`\nFROM `users` AS `u`\nLEFT JOIN `group_users` AS `gu` ON `gu`.`user_id` = `u`.`id`\nLEFT JOIN `groups` AS `g` ON `g`.`id` = `gu`.`group_id`\nLEFT JOIN `user_roles` AS `ur` ON `ur`.`user_id` = `u`.`id`"; + const result = qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true }); + expect(result).toContain("FROM `aaa`.`users` AS `u`"); + expect(result).toContain("LEFT JOIN `aaa`.`group_users` AS `gu`"); + expect(result).toContain("LEFT JOIN `aaa`.`groups` AS `g`"); + expect(result).toContain("LEFT JOIN `aaa`.`user_roles` AS `ur`"); + }); + + it("leaves already-qualified sources unchanged", () => { + const options = { databaseType: "mysql" as const, database: "aaa", includeDatabaseName: true }; + expect(qualifyTableReferencesInSql("SELECT * FROM `aaa`.`apis`", options)).toBe("SELECT * FROM `aaa`.`apis`"); + }); + + it("qualifies physical tables inside CTEs but never CTE references", () => { + const sql = "WITH users AS (SELECT * FROM archived_users)\nSELECT * FROM users"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("WITH users AS (SELECT * FROM `aaa`.`archived_users`)\nSELECT * FROM users"); + }); + + it("preserves CTE references when the statement has a trailing semicolon", () => { + const sql = "WITH users AS (SELECT * FROM archived_users) SELECT * FROM users;"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("WITH users AS (SELECT * FROM `aaa`.`archived_users`) SELECT * FROM users;"); + }); + + it("keeps CTE visibility scoped to each statement", () => { + const sql = "WITH users AS (SELECT * FROM archived_users) SELECT * FROM users; WITH roles AS (SELECT * FROM archived_roles) SELECT * FROM roles;"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("WITH users AS (SELECT * FROM `aaa`.`archived_users`) SELECT * FROM users; WITH roles AS (SELECT * FROM `aaa`.`archived_roles`) SELECT * FROM roles;"); + }); + + it("does not treat a later CTE name as visible inside an earlier CTE", () => { + const sql = "WITH first AS (SELECT * FROM second), second AS (SELECT * FROM archived_users) SELECT * FROM first"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("WITH first AS (SELECT * FROM `aaa`.`second`), second AS (SELECT * FROM `aaa`.`archived_users`) SELECT * FROM first"); + }); + + it("does not leak a nested CTE name into the outer query", () => { + const sql = "SELECT * FROM (WITH users AS (SELECT * FROM archived_users) SELECT * FROM users) nested JOIN users ON 1 = 1"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("SELECT * FROM (WITH users AS (SELECT * FROM `aaa`.`archived_users`) SELECT * FROM users) nested JOIN `aaa`.`users` ON 1 = 1"); + }); + + it("keeps outer CTEs visible inside nested WITH queries", () => { + const sql = "WITH users AS (SELECT * FROM archived_users) SELECT * FROM (WITH roles AS (SELECT * FROM users) SELECT * FROM roles) nested"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("WITH users AS (SELECT * FROM `aaa`.`archived_users`) SELECT * FROM (WITH roles AS (SELECT * FROM users) SELECT * FROM roles) nested"); + }); + + it("does not let a nested CTE suppress an earlier outer physical table", () => { + const sql = "SELECT * FROM users WHERE EXISTS (WITH users AS (SELECT * FROM archived_users) SELECT * FROM users)"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("SELECT * FROM `aaa`.`users` WHERE EXISTS (WITH users AS (SELECT * FROM `aaa`.`archived_users`) SELECT * FROM users)"); + }); + + it("does not rewrite FROM and JOIN text inside literals or comments", () => { + const sql = "SELECT 'FROM users JOIN groups' AS note\nFROM users -- JOIN groups"; + expect(qualifyTableReferencesInSql(sql, { databaseType: "mysql", database: "aaa", includeDatabaseName: true })).toBe("SELECT 'FROM users JOIN groups' AS note\nFROM `aaa`.`users` -- JOIN groups"); + }); + + it("uses GoldenDB's MySQL-compatible identifier quoting", () => { + expect(qualifyTableReferencesInSql("SELECT * FROM users", { databaseType: "goldendb", database: "aaa", includeDatabaseName: true })).toBe("SELECT * FROM `aaa`.`users`"); + }); +}); + describe("qualifiedTableName — SQLite attached databases", () => { it("qualifies tables with the attached database alias", () => { expect(qualifiedTableName({ databaseType: "sqlite", schema: "analytics", tableName: "events" })).toBe('"analytics"."events"'); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 6e17bcd1fb..a2d71d25ed 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1291,7 +1291,7 @@ export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlO } export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise { - return post("/api/query/build-table-select-sql", { options }); + return post("/api/query/build-table-select-sql", { options, includeDatabaseName: options.includeDatabaseName === true }); } export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 8422b2bca6..2339ce9936 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1452,7 +1452,7 @@ export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlO } export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise { - return invoke("build_table_select_sql", { options }); + return invoke("build_table_select_sql", { options, includeDatabaseName: options.includeDatabaseName === true }); } export async function buildDatabaseSearchSql(options: DatabaseSearchSqlOptions): Promise { diff --git a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts index d0b42db6ce..4e46384cd9 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts @@ -186,6 +186,7 @@ describe("settings search", () => { { titleKey: "settings.completionTriggerMode", category: "editor", targetId: "editor" }, { titleKey: "settings.autoAliasTables", category: "editor", targetId: "editor" }, { titleKey: "settings.clickTableNavigationTarget", category: "editor", targetId: "editor" }, + { titleKey: "settings.generateSqlIncludeDatabaseName", category: "editor", targetId: "editor" }, { titleKey: "settings.sqlFormatterKeywordCase", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterFunctionCase", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterDataTypeCase", category: "formatter", targetId: "formatter" }, diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 6b5df737f9..470d3f678a 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -65,6 +65,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "dataTabReuseMode", "openDataTabsNextToActive", "prefillNewQueryWithSelect", + "generateSqlIncludeDatabaseName", "updateNotificationsEnabled", "sidebarObjectInfoMode", "sidebarAllowHorizontalScroll", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index 69cddd8e36..f88414a27f 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -120,6 +120,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "editor-unsaved-close", category: "editor", titleKey: "settings.confirmUnsavedSqlClose", descriptionKey: "settings.confirmUnsavedSqlCloseDescription", targetId: "editor" }, { id: "editor-app-close-unsaved-tabs", category: "editor", titleKey: "settings.appCloseUnsavedTabsMode", descriptionKey: "settings.appCloseUnsavedTabsModeDescription", targetId: "editor" }, { id: "editor-prefill-query", category: "editor", titleKey: "settings.prefillNewQueryWithSelect", descriptionKey: "settings.prefillNewQueryWithSelectDescription", targetId: "editor" }, + { id: "editor-generate-sql-include-database", category: "editor", titleKey: "settings.generateSqlIncludeDatabaseName", descriptionKey: "settings.generateSqlIncludeDatabaseNameDescription", targetId: "editor" }, { id: "editor-diagnostics", category: "editor", titleKey: "settings.sqlSemanticDiagnosticsEnabled", descriptionKey: "settings.sqlSemanticDiagnosticsEnabledDescription", targetId: "editor" }, { id: "editor-sql-variables", category: "editor", titleKey: "settings.sqlVariableSyntax", descriptionKey: "settings.sqlVariableSyntaxDescription", targetId: "editor" }, { id: "editor-saved-sql-target", category: "editor", titleKey: "settings.savedSqlOpenTarget", targetId: "editor" }, diff --git a/apps/desktop/src/lib/sql/newQueryContext.ts b/apps/desktop/src/lib/sql/newQueryContext.ts index 89034dcba3..1409be7a12 100644 --- a/apps/desktop/src/lib/sql/newQueryContext.ts +++ b/apps/desktop/src/lib/sql/newQueryContext.ts @@ -92,6 +92,7 @@ export interface ResolveNewQueryInitialSqlInput extends ResolveNewQueryTableInpu databaseType?: DatabaseType; driverProfile?: string; identifierQuote?: string; + includeDatabaseName?: boolean; } // Database types whose "table" view does not use standard SQL `SELECT * FROM
` @@ -147,9 +148,9 @@ export function resolveNewQueryTable(input: ResolveNewQueryTableInput): NewQuery * the same per-dialect identifier quoting and schema/catalog qualification used * by the table-data view. */ -export function buildSelectAllSql(databaseType: DatabaseType | undefined, table: Pick & Partial>, identifierQuote?: string, driverProfile?: string): string { +export function buildSelectAllSql(databaseType: DatabaseType | undefined, table: Pick & Partial>, identifierQuote?: string, driverProfile?: string, includeDatabaseName = false): string { if (databaseType === "victoriametrics") return metricRangeQuery(table.tableName); - const ref = qualifiedTableName({ databaseType, driverProfile, identifierQuote, database: table.database, schema: table.schema, catalog: table.catalog, tableName: table.tableName }); + const ref = qualifiedTableName({ databaseType, driverProfile, identifierQuote, database: table.database, schema: table.schema, catalog: table.catalog, tableName: table.tableName, includeDatabaseName }); return `SELECT * FROM ${ref}`; } @@ -164,5 +165,5 @@ export function resolveNewQueryInitialSql(input: ResolveNewQueryInitialSqlInput) const table = resolveNewQueryTable(input); if (!table || table.connectionId !== input.targetConnectionId || table.database !== input.targetDatabase) return undefined; - return buildSelectAllSql(input.databaseType, table, input.identifierQuote, input.driverProfile); + return buildSelectAllSql(input.databaseType, table, input.identifierQuote, input.driverProfile, input.includeDatabaseName); } diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index 5c9ec50451..f17b5d72b2 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -4,6 +4,10 @@ import { jdbcDriverProfileUsesSchemaQualification } from "@/lib/database/jdbcDia import * as api from "@/lib/backend/api.ts"; import { parseSqlServerLinkedSchema, sqlServerLinkedTableName } from "@/lib/database/sqlServerLinkedServers.ts"; import { isExplicitlyQuotedSqlIdentifier, quoteGaussDbJdbcIdentifier } from "@/lib/sql/sqlIdentifier.ts"; +import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; +import { sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model"; +import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens"; +import type { SqlSemanticToken } from "@/lib/sql/semantic/types"; export interface BuildTableSelectSqlOptions { databaseType?: DatabaseType; @@ -25,6 +29,30 @@ export interface BuildTableSelectSqlOptions { includeRowId?: boolean; catalog?: string; database?: string; + /** Include the active database when this dialect supports `database.table` references. */ + includeDatabaseName?: boolean; +} + +const DATABASE_QUALIFIED_TABLE_TYPES = new Set(["mysql", "clickhouse", "doris", "starrocks", "goldendb"]); + +function sqlStatementSpans(sql: string, dialectId: string): Array<{ start: number; end: number }> { + const spans: Array<{ start: number; end: number }> = []; + let start = 0; + const appendSpan = (end: number) => { + let trimmedStart = start; + let trimmedEnd = end; + while (trimmedStart < trimmedEnd && /\s/.test(sql[trimmedStart] ?? "")) trimmedStart += 1; + while (trimmedEnd > trimmedStart && /\s/.test(sql[trimmedEnd - 1] ?? "")) trimmedEnd -= 1; + if (trimmedStart < trimmedEnd) spans.push({ start: trimmedStart, end: trimmedEnd }); + }; + + for (const token of tokenizeSqlSemantic(sql, dialectId)) { + if (token.kind !== "punctuation" || token.text !== ";" || token.depth !== 0) continue; + appendSpan(token.span.start); + start = token.span.end; + } + appendSpan(sql.length); + return spans; } export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string { @@ -49,7 +77,8 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam databaseType === "tdengine" || databaseType === "access" || databaseType === "doris" || - databaseType === "starrocks" + databaseType === "starrocks" || + databaseType === "goldendb" ) return `\`${name.replace(/`/g, "``")}\``; if (databaseType === "informix" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(name)) return name; @@ -75,8 +104,8 @@ function quoteCypherIdentifier(name: string): string { return `\`${name.replace(/`/g, "``")}\``; } -export function qualifiedTableName(options: Pick): string { - const { databaseType, driverProfile, identifierQuote, schema, tableName, catalog, database } = options; +export function qualifiedTableName(options: Pick): string { + const { databaseType, driverProfile, identifierQuote, schema, tableName, catalog, database, includeDatabaseName } = options; if (databaseType === "informix" && driverProfile?.trim().toLowerCase() === "gbase8s") { return quoteTableDataIdentifier(databaseType, tableName, identifierQuote); } @@ -143,9 +172,111 @@ export function qualifiedTableName(options: Pick index > withIndex && token.depth < depth)?.span.start ?? sqlLength; + let index = withIndex + 1; + if (tokens[index]?.depth === depth && tokens[index]?.normalized === "recursive") index += 1; + + while (index < tokens.length) { + while (tokens[index]?.depth === depth && tokens[index]?.text === ",") index += 1; + const nameToken = tokens[index]; + if (!nameToken || nameToken.depth !== depth || !tokenIsIdentifier(nameToken)) break; + index += 1; + + if (tokens[index]?.depth === depth && tokens[index]?.text === "(") { + const columnsClose = matchingSqlParenthesisToken(tokens, index); + if (columnsClose < 0) break; + index = columnsClose + 1; + } + if (tokens[index]?.depth === depth && tokens[index]?.normalized === "as") index += 1; + if (tokens[index]?.depth !== depth || tokens[index]?.text !== "(") break; + const bodyOpen = index; + const bodyClose = matchingSqlParenthesisToken(tokens, bodyOpen); + if (bodyClose < 0) break; + visibilities.push({ + name: unquoteSqlSemanticIdentifier(nameToken), + visibleFrom: tokens[bodyOpen]!.span.end, + visibleUntil: scopeEnd, + }); + index = bodyClose + 1; + if (tokens[index]?.depth !== depth || tokens[index]?.text !== ",") break; + } + } + return visibilities; +} + +/** + * Qualifies physical table sources shown in a result footer without changing + * the SQL that was actually executed. The semantic model deliberately skips + * CTE names, strings, and comments that can happen to contain FROM/JOIN text. + */ +export function qualifyTableReferencesInSql(sql: string, options: Pick): string { + if (!options.includeDatabaseName || !options.databaseType || !DATABASE_QUALIFIED_TABLE_TYPES.has(options.databaseType) || !options.database?.trim()) return sql; + const database = quoteTableIdentifier(options.databaseType, options.database.trim()); + // Build replacements from right to left so that every semantic span still + // points at the original source text. Only one-part physical table names + // need the active database prefix; CTEs and already-qualified tables do not. + const semanticOptions = { + databaseType: options.databaseType, + dialect: options.databaseType === "goldendb" ? "mysql" : undefined, + } as const; + const dialectId = sqlSemanticDialectFor(semanticOptions).id; + const replacements = sqlStatementSpans(sql, dialectId) + .flatMap(({ start, end }) => { + const statementSql = sql.slice(start, end); + const tokens = tokenizeSqlSemantic(statementSql, dialectId); + const cteVisibilities = sqlCteVisibilities(tokens, statementSql.length); + const isCteReference = (name: string, span: { start: number; end: number }): boolean => cteVisibilities.some((cte) => cte.name.toLowerCase() === name.toLowerCase() && span.start >= cte.visibleFrom && span.end <= cte.visibleUntil); + const tokensBySpan = new Map(tokens.map((token) => [`${token.span.start}:${token.span.end}`, token])); + + return sqlSemanticTableNameSpans(statementSql, semanticOptions) + .map((span) => ({ span, token: tokensBySpan.get(`${span.start}:${span.end}`) })) + .filter(({ span, token }) => { + if (!token || isCteReference(unquoteSqlSemanticIdentifier(token), span)) return false; + // sqlSemanticTableNameSpans returns the final segment in a qualified + // name, so a preceding dot identifies a database-qualified source. + return !statementSql.slice(0, span.start).trimEnd().endsWith("."); + }) + .map(({ span, token }) => ({ + span: { start: start + span.start, end: start + span.end }, + tableName: unquoteSqlSemanticIdentifier(token!), + })); + }) + .filter(({ span }, index, all) => all.findIndex((candidate) => candidate.span.start === span.start && candidate.span.end === span.end) === index) + .sort((left, right) => right.span.start - left.span.start); + + return replacements.reduce((qualifiedSql, { span, tableName }) => `${qualifiedSql.slice(0, span.start)}${database}.${quoteTableIdentifier(options.databaseType, tableName)}${qualifiedSql.slice(span.end)}`, sql); +} + export function metricSelector(metricName: string): string { const escaped = metricName.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n"); return `{__name__="${escaped}"}`; diff --git a/apps/desktop/src/lib/table/tableSqlTemplates.ts b/apps/desktop/src/lib/table/tableSqlTemplates.ts index 0e59f014ca..5954cbca9f 100644 --- a/apps/desktop/src/lib/table/tableSqlTemplates.ts +++ b/apps/desktop/src/lib/table/tableSqlTemplates.ts @@ -8,6 +8,7 @@ export interface TableSqlTemplateOptions { schema?: string; catalog?: string; database?: string; + includeDatabaseName?: boolean; tableName: string; columns?: ColumnInfo[]; tableType?: string; @@ -105,6 +106,7 @@ function templateTableName(options: TableSqlTemplateOptions): string { identifierQuote: options.identifierQuote, catalog: options.catalog, database: options.database, + includeDatabaseName: options.includeDatabaseName, schema: options.schema, tableName: options.tableName, }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 235e72d966..bea5121fbb 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2898,6 +2898,7 @@ export const useQueryStore = defineStore("query", () => { columns: tableMeta.columns.map((column) => column.name), primaryKeys, ...tableDataLargeValuePreviewOptions(effectiveDbType, tableMeta.columns, primaryKeys, limit), + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, includeRowId: usesSyntheticRowIdKey(effectiveDbType, primaryKeys, tableMeta.tableType), whereInput: tab.whereInput, orderBy, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 7c1746608a..ab9a975279 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -601,6 +601,7 @@ export interface EditorSettings { dataTabReuseMode: DataTabReuseMode; openDataTabsNextToActive: boolean; prefillNewQueryWithSelect: boolean; + generateSqlIncludeDatabaseName: boolean; updateNotificationsEnabled: boolean; sidebarHiddenTablePrefixes: string[]; sidebarObjectInfoMode: SidebarObjectInfoMode; @@ -807,6 +808,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { dataTabReuseMode: DEFAULT_DATA_TAB_REUSE_MODE, openDataTabsNextToActive: false, prefillNewQueryWithSelect: true, + generateSqlIncludeDatabaseName: false, updateNotificationsEnabled: true, sidebarHiddenTablePrefixes: [], sidebarObjectInfoMode: "comment-inline", @@ -1199,6 +1201,7 @@ export function normalizeEditorSettings(settings: Partial, exist ), openDataTabsNextToActive: typeof settings.openDataTabsNextToActive === "boolean" ? settings.openDataTabsNextToActive : DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive, prefillNewQueryWithSelect: typeof settings.prefillNewQueryWithSelect === "boolean" ? settings.prefillNewQueryWithSelect : DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect, + generateSqlIncludeDatabaseName: settings.generateSqlIncludeDatabaseName === true, updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled, sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes), sidebarObjectInfoMode: normalizeSidebarObjectInfoMode( @@ -1788,6 +1791,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.dataTabReuseMode !== undefined) editorSettings.value.dataTabReuseMode = normalizeDataTabReuseMode(partial.dataTabReuseMode); if (partial.openDataTabsNextToActive !== undefined) editorSettings.value.openDataTabsNextToActive = partial.openDataTabsNextToActive === true; if (partial.prefillNewQueryWithSelect !== undefined) editorSettings.value.prefillNewQueryWithSelect = partial.prefillNewQueryWithSelect; + if (partial.generateSqlIncludeDatabaseName !== undefined) editorSettings.value.generateSqlIncludeDatabaseName = partial.generateSqlIncludeDatabaseName === true; if (partial.updateNotificationsEnabled !== undefined) editorSettings.value.updateNotificationsEnabled = partial.updateNotificationsEnabled; if (partial.sidebarHiddenTablePrefixes !== undefined) editorSettings.value.sidebarHiddenTablePrefixes = normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes); if (partial.sidebarObjectInfoMode !== undefined) editorSettings.value.sidebarObjectInfoMode = normalizeSidebarObjectInfoMode(partial.sidebarObjectInfoMode); diff --git a/crates/dbx-core/src/sql_dialect.rs b/crates/dbx-core/src/sql_dialect.rs index a3fddf5b1b..177bcc682c 100644 --- a/crates/dbx-core/src/sql_dialect.rs +++ b/crates/dbx-core/src/sql_dialect.rs @@ -39,7 +39,8 @@ pub use identifiers::{ }; pub(crate) use identifiers::{parse_sqlserver_linked_schema_ref, qualified_transfer_table, quote_transfer_identifier}; pub use table_select::{ - build_count_table_sql, build_table_data_select_sql, build_table_select_sql, DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX, + build_count_table_sql, build_table_data_select_sql, build_table_data_select_sql_with_database, + build_table_select_sql, DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX, }; pub(crate) use table_select::{ quote_table_data_identifier, table_data_qualified_table_name, table_data_schema, uses_connection_identifier_quote, diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index eb5cd52cf6..f4338ccfcd 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -180,7 +180,16 @@ pub(crate) fn table_data_schema<'a>( } } +/// Builds the SQL used by the data-table grid. Database qualification is opt-in +/// so existing callers retain their current SQL shape. pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String { + build_table_data_select_sql_with_database(options, false) +} + +pub fn build_table_data_select_sql_with_database( + options: TableDataSelectSqlOptions, + include_database_name: bool, +) -> String { let database_type = options.database_type; let schema = table_data_schema(database_type, options.driver_profile.as_deref(), options.schema.as_deref()); let limit = options.limit.unwrap_or(100); @@ -202,6 +211,22 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String // Doris / StarRocks multi-catalog: prefix the catalog for external-catalog tables. } else if uses_connection_identifier_quote(database_type, options.identifier_quote.as_deref()) { table_data_qualified_table_name(database_type, schema, &options.table_name, options.identifier_quote.as_deref()) + } else if include_database_name { + database_qualified_table_name( + database_type, + options.catalog.as_deref(), + options.database.as_deref(), + &options.table_name, + ) + .unwrap_or_else(|| { + qualified_table_name_with_catalog( + database_type, + options.catalog.as_deref(), + schema, + options.database.as_deref(), + &options.table_name, + ) + }) } else { qualified_table_name_with_catalog( database_type, @@ -343,6 +368,29 @@ pub fn build_table_data_select_sql(options: TableDataSelectSqlOptions) -> String } } +/// Returns a `database.table` reference for engines whose active database is +/// normally omitted from table-data SQL. Doris and StarRocks retain an external +/// catalog prefix when one is selected. +fn database_qualified_table_name( + database_type: Option, + catalog: Option<&str>, + database: Option<&str>, + table_name: &str, +) -> Option { + let database = database.map(str::trim).filter(|database| !database.is_empty())?; + match database_type { + Some(DatabaseType::ClickHouse) => Some(format!( + "{}.{}", + quote_table_identifier(database_type, database), + quote_table_identifier(database_type, table_name) + )), + Some(DatabaseType::Mysql | DatabaseType::Goldendb | DatabaseType::Doris | DatabaseType::StarRocks) => { + Some(qualified_table_name_with_catalog(database_type, catalog, Some(database), Some(database), table_name)) + } + _ => None, + } +} + pub(crate) fn table_data_qualified_table_name( database_type: Option, schema: Option<&str>, @@ -747,6 +795,13 @@ mod tests { assert!(sql.contains("FROM `hive_catalog`.`sales`.`orders`"), "sql was: {sql}"); } + #[test] + fn table_data_select_optionally_qualifies_database() { + let options = opts(DatabaseType::Mysql, None, Some("aaa"), "apis"); + assert_eq!(build_table_data_select_sql(options.clone()), "SELECT * FROM `apis` LIMIT 10;"); + assert_eq!(build_table_data_select_sql_with_database(options, true), "SELECT * FROM `aaa`.`apis` LIMIT 10;"); + } + #[test] fn doris_external_catalog_without_database_degrades_to_two_part() { // When neither schema nor database is provided the name degrades to the diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 71bca850ed..42d7810cfb 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -126,6 +126,8 @@ pub struct BuildDroppedFilePreviewSqlRequest { #[serde(rename_all = "camelCase")] pub struct BuildTableSelectSqlRequest { pub options: dbx_core::sql_dialect::TableDataSelectSqlOptions, + #[serde(default)] + pub include_database_name: bool, } #[derive(Deserialize)] @@ -782,7 +784,7 @@ pub async fn build_dropped_file_preview_sql( } pub async fn build_table_select_sql(Json(req): Json) -> Json { - Json(dbx_core::sql_dialect::build_table_data_select_sql(req.options)) + Json(dbx_core::sql_dialect::build_table_data_select_sql_with_database(req.options, req.include_database_name)) } pub async fn build_database_search_sql( diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts index fb89e12027..8da9b7f234 100644 --- a/packages/app-tests/queryResultToolbar.test.ts +++ b/packages/app-tests/queryResultToolbar.test.ts @@ -111,9 +111,9 @@ test("DataGrid exposes persistent result toolbar slots", () => { test("table-data toolbar refresh keeps page size independent from SQL editor settings", () => { const dataGrid = source(dataGridPath); - assert.match(dataGrid, /props\.context === "table-data" \? \(props\.pageLimit \?\? tableOpenPageLimit\(settingsStore\.editorSettings\.tableOpenPageSize\)\) : settingsStore\.editorSettings\.pageSize/); - assert.match(dataGrid, /if \(props\.context === "table-data"\) return;[\s\S]*pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/); - assert.match(dataGrid, /props\.context === "table-data" \? \{ tableOpenPageSize: normalizedSize \} : \{ pageSize: normalizedSize \}/); + assert.match(dataGrid, /preferredDataGridPageSize\(settingsStore\.editorSettings, pageSizePreference\.value, props\.pageLimit\)/); + assert.match(dataGrid, /if \(pageSizePreference\.value !== "results"\) return;[\s\S]*pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/); + assert.match(dataGrid, /dataGridPageSizeSettingsPatch\(pageSizePreference\.value, normalizedSize\)/); assert.match(dataGrid, /const resetToFirstPage = hasPendingConditionInputs\(\);/); assert.match(dataGrid, /emit\("reload", props\.sql, searchText\.value, currentWhereInput\(\), currentOrderBy\(\), pageSize\.value, resetToFirstPage \? 0 : \(currentPage\.value - 1\) \* pageSize\.value, "refresh"\)/); }); diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 02479d467b..0bb2dc28ec 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -533,8 +533,14 @@ pub fn build_dropped_file_preview_sql( } #[tauri::command] -pub fn build_table_select_sql(options: dbx_core::sql_dialect::TableDataSelectSqlOptions) -> Result { - Ok(dbx_core::sql_dialect::build_table_data_select_sql(options)) +pub fn build_table_select_sql( + options: dbx_core::sql_dialect::TableDataSelectSqlOptions, + include_database_name: Option, +) -> Result { + Ok(dbx_core::sql_dialect::build_table_data_select_sql_with_database( + options, + include_database_name.unwrap_or(false), + )) } #[tauri::command]