fix(grid): qualify generated save and copy SQL with database name

This commit is contained in:
zipg
2026-09-21 11:51:34 +08:00
committed by GitHub
parent 865b9fba84
commit 420cb80e65
32 changed files with 910 additions and 123 deletions
@@ -48,7 +48,7 @@ import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
import { canFormatSqlForDatabaseType, formatSqlForDisplay, formatSqlForEditing, compressSqlText, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
@@ -3561,7 +3561,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
// the aligned column layout from reformatHoverDdl.
const isViewObject = objectMetadataRequest.objectType === "VIEW" || objectMetadataRequest.objectType === "MATERIALIZED_VIEW";
const formatted = isViewObject ? await formatSqlForDisplay(rawDdl, formatDialect, settingsStore.editorSettings.sqlFormatter) : reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName));
const unqualified = omitDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
const unqualified = applyDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, hoverDatabase, props.catalog);
sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
}
} catch (error) {
@@ -3595,7 +3595,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
}
if (fullColumns.length > 0) {
sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment);
sqlContent = omitDdlDatabaseQualifier(sqlContent, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
sqlContent = applyDdlDatabaseQualifier(sqlContent, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, hoverDatabase, props.catalog);
if (!settingsStore.editorSettings.generateSqlQuoteIdentifiers) sqlContent = omitDdlIdentifierQuotes(sqlContent, formatDialect);
metadataLoadFailed = false;
}
@@ -3719,6 +3719,7 @@ const editor = useDataGridEditor({
rowStatusFilter,
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
includeDatabaseNameInSaveSql: computed(() => settingsStore.editorSettings.generateSqlIncludeDatabaseName),
initialEditColumn: firstVisibleColumnIndex,
cellEditorText: cellEditorTextForValue,
normalizeEditorInput: (value) => (props.mongoCollectionGrid ? mongoDocumentGridInputValue(value) : value),
@@ -11,7 +11,7 @@ import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { ddlFormatDialectFor, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, ddlFormatDialectFor, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -131,7 +131,7 @@ async function loadDdl(force = false) {
);
const formatDialect = ddlFormatDialectFor({ formatDialect: props.formatDialect, databaseType: props.databaseType, highlightDialect: props.dialect });
const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter);
const unqualified = omitDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
const unqualified = applyDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.database, props.catalog);
rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
} catch (e: any) {
ddlError.value = e?.message || String(e);
@@ -122,7 +122,7 @@ import { useQueryStore } from "@/stores/queryStore";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
@@ -1194,7 +1194,7 @@ async function fetchTableDdl(force = settingsStore.editorSettings.refreshDdlOnOp
const { ddl } = await loadObjectDdl(tableMetadataRequest(row), { force });
if (sidePanelGuard.isStale(epoch)) return;
const formatDialect = sqlFormatDialectForDbType(effectiveDatabaseType.value);
const unqualified = omitDdlDatabaseQualifier(ddl, formatDialect, effectiveDatabaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
const unqualified = applyDdlDatabaseQualifier(ddl, formatDialect, effectiveDatabaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.database, props.catalog);
rawTableDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
loadedSuccessfully = true;
} catch (e: any) {
@@ -2188,7 +2188,7 @@ async function openSidebarMultiTableDdlTab(targets: Array<TreeNode & { connectio
async (ddl, target) => {
const formatDialect = sqlFormatDialectForDbType(databaseTypeForNode(target));
const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter);
return formatSidebarDdlTemplateForDisplay(formatted, formatDialect, databaseTypeForNode(target), settingsStore.editorSettings.generateSqlIncludeDatabaseName, settingsStore.editorSettings.generateSqlQuoteIdentifiers, target.catalog);
return formatSidebarDdlTemplateForDisplay(formatted, formatDialect, databaseTypeForNode(target), settingsStore.editorSettings.generateSqlIncludeDatabaseName, target.database, settingsStore.editorSettings.generateSqlQuoteIdentifiers, target.catalog);
},
);
connectionStore.activeConnectionId = tabTarget.connectionId;
@@ -31,7 +31,7 @@ import { useToast } from "@/composables/useToast";
import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript";
import { formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { copyToClipboard } from "@/lib/common/clipboard";
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
@@ -371,7 +371,7 @@ function scheduleDdlEditorInit() {
* generated from the pending structure changes.
*/
function formatDdlForDisplay(sql: string, dialect: SqlFormatDialect, generated = false): string {
const unqualified = omitDdlDatabaseQualifier(sql, dialect, databaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
const unqualified = applyDdlDatabaseQualifier(sql, dialect, databaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.database, props.catalog);
if (settingsStore.editorSettings.generateSqlQuoteIdentifiers) return unqualified;
return generated ? formatGeneratedDdlIdentifierQuotes(unqualified, dialect, false) : omitDdlIdentifierQuotes(unqualified, dialect);
}
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import { computed, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridEditor } from "@/composables/useDataGridEditor";
import type { CellValue } from "@/lib/dataGrid/cellValue";
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
prepareDataGridSave: vi.fn(),
executeBatch: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
prepareDataGridSave: mocks.prepareDataGridSave,
executeBatch: mocks.executeBatch,
executeConditionalUpdate: vi.fn(),
cancelConditionalUpdate: vi.fn(),
executeInTransaction: vi.fn(),
executeInManualTransaction: vi.fn(),
executeQuery: vi.fn(),
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ getConfig: mocks.getConfig }),
}));
vi.mock("@/stores/historyStore", () => ({
useHistoryStore: () => ({ add: vi.fn() }),
}));
vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({}),
}));
function createEditor(includeDatabaseNameInSaveSql: boolean) {
const rows = ref<CellValue[][]>([["alpha"]]);
let editor: ReturnType<typeof useDataGridEditor>;
editor = useDataGridEditor({
result: computed(() => ({ columns: ["name"], rows: rows.value })),
editable: computed(() => true),
databaseType: computed(() => "mysql"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
tableMeta: computed(() => ({
database: "dbx",
tableName: "filter_probe",
columns: [{ name: "name", data_type: "varchar(50)" }],
primaryKeys: ["id"],
})),
sourceColumns: computed(() => undefined),
includeDatabaseNameInSaveSql: computed(() => includeDatabaseNameInSaveSql),
onExecuteSql: computed(() => undefined),
sql: computed(() => undefined),
searchText: ref(""),
whereFilterInput: ref(""),
currentWhereInput: computed(() => undefined),
orderByInput: ref(""),
rowStatusFilter: ref("all"),
pageSize: ref(100),
currentPage: ref(1),
onCellValueChanged: undefined,
getRowItem: (rowId) => {
if (rowId < 0) return undefined;
const row = rows.value[rowId];
if (!row) return undefined;
const changes = editor.dirtyRows.value.get(rowId);
return {
id: rowId,
sourceIndex: rowId,
data: row.map((value, columnIndex) => (changes?.has(columnIndex) ? (changes.get(columnIndex) ?? null) : value)),
isNew: false,
isDeleted: false,
isDirtyCol: row.map((_, columnIndex) => changes?.has(columnIndex) ?? false),
status: changes?.size ? "edited" : "normal",
};
},
emit: vi.fn(),
});
editor.applyCellValue(0, 0, "changed");
return editor;
}
describe("useDataGridEditor save SQL database qualification", () => {
beforeEach(() => {
mocks.prepareDataGridSave.mockReset();
mocks.executeBatch.mockReset();
mocks.getConfig.mockReset();
mocks.getConfig.mockReturnValue(undefined);
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE x"], rollbackStatements: [] });
mocks.executeBatch.mockResolvedValue([]);
});
it("forwards the include-database-name setting to the save statement builder", async () => {
await createEditor(true).saveChanges();
expect(mocks.prepareDataGridSave.mock.calls[0]![0]).toMatchObject({ includeDatabaseName: true });
await createEditor(false).saveChanges();
expect(mocks.prepareDataGridSave.mock.calls[1]![0]).toMatchObject({ includeDatabaseName: false });
});
});
@@ -119,6 +119,8 @@ export interface UseDataGridEditorOptions {
rowStatusFilter: Ref<RowStatusFilter>;
dataGridQuickEntryEnabled?: ComputedRef<boolean>;
confirmDangerousRowDeletion?: ComputedRef<boolean>;
/** `生成 SQL 时包含数据库名` — qualify saved tables with their database. */
includeDatabaseNameInSaveSql?: ComputedRef<boolean>;
initialEditColumn?: ComputedRef<number>;
/** Converts a grid value to the text presented by the cell editor. */
cellEditorText?: (value: CellValue, columnIndex: number) => string;
@@ -246,6 +248,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
rowStatusFilter,
dataGridQuickEntryEnabled = computed(() => false),
confirmDangerousRowDeletion = computed(() => true),
includeDatabaseNameInSaveSql = computed(() => false),
initialEditColumn,
cellEditorText,
normalizeEditorInput,
@@ -1572,6 +1575,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows: [...snapshot.dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>]),
deletedRows: [...snapshot.deletedRows],
newRows: snapshot.newRows,
includeDatabaseName: includeDatabaseNameInSaveSql.value,
};
}
@@ -1,6 +1,6 @@
import { computed, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/backend/api";
import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
@@ -119,7 +119,7 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo
try {
const { ddl } = await loadObjectDdl(request, { force });
const formatDialect = sqlFormatDialectForDbType(options.resolvedDatabaseType.value);
const unqualified = omitDdlDatabaseQualifier(ddl, formatDialect, options.resolvedDatabaseType.value, options.settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.tableMeta?.catalog);
const unqualified = applyDdlDatabaseQualifier(ddl, formatDialect, options.resolvedDatabaseType.value, options.settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.tableMeta?.database, props.tableMeta?.catalog);
state.ddlContent.value = options.settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
} catch (error: any) {
state.ddlContent.value = `-- Error: ${error}`;
+1 -1
View File
@@ -7798,7 +7798,7 @@ export default {
prefillNewQueryWithSelect: "Prefill new query with SELECT *",
prefillNewQueryWithSelectDescription: "When creating a new query, prefill the editor with SELECT * FROM <table> 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.",
generateSqlIncludeDatabaseNameDescription: "When supported by the database, generated table SQL uses the qualified name (database.schema.table on SQL Server) instead of only the table name.",
generateSqlQuoteIdentifiers: "Quote identifiers in generated SQL",
generateSqlQuoteIdentifiersDescription: "When disabled, new query table references omit dialect-specific quotes. Reserved words, special characters, and case-sensitive names may require quotes to run.",
formatSqlOnSqlFileSave: "Format SQL when saving SQL files",
+1 -1
View File
@@ -7768,7 +7768,7 @@ export default withEnglishFallback({
prefillNewQueryWithSelect: "新建查询时预填充 SELECT 语句",
prefillNewQueryWithSelectDescription: "新建查询时,根据当前激活的数据表标签页或侧边栏选中的表,自动在编辑器中填充 SELECT * FROM <表名>。",
generateSqlIncludeDatabaseName: "生成 SQL 时包含数据库名",
generateSqlIncludeDatabaseNameDescription: "数据库支持时,生成的表 SQL 将使用“数据库名.表名”,而不只包含表名。",
generateSqlIncludeDatabaseNameDescription: "数据库支持时,生成的表 SQL 将使用限定名(SQL Server 为“数据库名.schema.表名”),而不只包含表名。",
generateSqlQuoteIdentifiers: "生成 SQL 时为标识符添加引号",
generateSqlQuoteIdentifiersDescription: "关闭后,新建查询中的表引用将不再使用数据库方言引号。保留字、特殊字符或大小写敏感名称可能仍需手动添加引号。",
formatSqlOnSqlFileSave: "保存 SQL 文件时格式化 SQL",
@@ -30,7 +30,7 @@ describe("sidebar DDL template", () => {
const sql = await buildSidebarDdlTemplateSql(
[{ databaseType: "oracle" as const, catalog: undefined }],
async () => 'CREATE TABLE "SYSTEM"."TEST" ("ID" NUMBER)',
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "oracle", target.databaseType, false, true, target.catalog),
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "oracle", target.databaseType, false, undefined, true, target.catalog),
);
expect(sql).toBe('CREATE TABLE "TEST" ("ID" NUMBER)');
@@ -44,7 +44,7 @@ describe("sidebar DDL template", () => {
const sql = await buildSidebarDdlTemplateSql(
targets,
async (target) => `CREATE TABLE \`iceberg\`.\`analytics\`.\`${target.name}\` (\`id\` bigint)`,
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "mysql", "starrocks", false, false, target.catalog),
async (ddl, target) => formatSidebarDdlTemplateForDisplay(ddl, "mysql", "starrocks", false, undefined, false, target.catalog),
);
expect(sql).toBe("CREATE TABLE iceberg.analytics.events (id bigint);\n\nCREATE TABLE iceberg.analytics.sessions (id bigint);\n");
@@ -44,6 +44,24 @@ describe("qualifiedTableName — optional database qualification", () => {
});
});
describe("qualifiedTableName — SQL Server three-part names", () => {
it("prefixes the database ahead of the schema once enabled", () => {
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema: "dbo", tableName: "gen_table" })).toBe("[dbo].[gen_table]");
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema: "dbo", tableName: "gen_table", includeDatabaseName: true })).toBe("[dbx].[dbo].[gen_table]");
});
it("keeps the schema-only form when the database is unknown", () => {
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema: "dbo", tableName: "gen_table", includeDatabaseName: true, quoteIdentifiers: false })).toBe("dbx.dbo.gen_table");
expect(qualifiedTableName({ databaseType: "sqlserver", schema: "dbo", tableName: "gen_table", includeDatabaseName: true })).toBe("[dbo].[gen_table]");
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", tableName: "gen_table", includeDatabaseName: true })).toBe("[gen_table]");
});
it("never stacks the local database on a linked-server schema", () => {
const schema = encodeSqlServerLinkedSchema({ server: "ERP", catalog: "Finance", schema: "dbo" });
expect(qualifiedTableName({ databaseType: "sqlserver", database: "dbx", schema, tableName: "orders", includeDatabaseName: true })).toBe("[ERP].[Finance].[dbo].[orders]");
});
});
describe("qualifyTableReferencesInSql", () => {
it("qualifies FROM and JOIN sources while preserving aliases", () => {
expect(
@@ -114,6 +132,37 @@ describe("qualifyTableReferencesInSql", () => {
});
});
describe("qualifyTableReferencesInSql — SQL Server three-part names (#9262)", () => {
const options = { databaseType: "sqlserver" as const, database: "dbx", includeDatabaseName: true };
it("prefixes the database ahead of the schema already named", () => {
expect(qualifyTableReferencesInSql("SELECT TOP (100) * FROM [dbo].[gen_table]", options)).toBe("SELECT TOP (100) * FROM [dbx].[dbo].[gen_table]");
});
it("qualifies every FROM/JOIN source while keeping aliases", () => {
expect(qualifyTableReferencesInSql("SELECT * FROM [dbo].[orders] AS o JOIN [sales].[items] AS i ON i.order_id = o.id", options)).toBe("SELECT * FROM [dbx].[dbo].[orders] AS o JOIN [dbx].[sales].[items] AS i ON i.order_id = o.id");
});
it("handles unquoted and spaced qualifiers", () => {
expect(qualifyTableReferencesInSql("SELECT * FROM dbo.gen_table", options)).toBe("SELECT * FROM [dbx].dbo.gen_table");
});
it("leaves already database-qualified and three-part names alone", () => {
expect(qualifyTableReferencesInSql("SELECT * FROM [other].[dbo].[gen_table]", options)).toBe("SELECT * FROM [other].[dbo].[gen_table]");
expect(qualifyTableReferencesInSql("SELECT * FROM [dbx_test].[dbo].[gen_table]", options)).toBe("SELECT * FROM [dbx_test].[dbo].[gen_table]");
});
it("leaves schema-less names alone because `db.table` is not a SQL Server reference", () => {
expect(qualifyTableReferencesInSql("SELECT * FROM [gen_table]", options)).toBe("SELECT * FROM [gen_table]");
expect(qualifyTableReferencesInSql("SELECT * FROM [dbx]..[gen_table]", options)).toBe("SELECT * FROM [dbx]..[gen_table]");
});
it("skips CTEs and stays inert when the setting is off", () => {
expect(qualifyTableReferencesInSql("WITH cte AS (SELECT * FROM [dbo].[gen_table]) SELECT * FROM cte", options)).toBe("WITH cte AS (SELECT * FROM [dbx].[dbo].[gen_table]) SELECT * FROM cte");
expect(qualifyTableReferencesInSql("SELECT * FROM [dbo].[gen_table]", { ...options, includeDatabaseName: false })).toBe("SELECT * FROM [dbo].[gen_table]");
});
});
describe("qualifiedTableName — SQLite attached databases", () => {
it("qualifies tables with the attached database alias", () => {
expect(qualifiedTableName({ databaseType: "sqlite", schema: "analytics", tableName: "events" })).toBe('"analytics"."events"');
@@ -33,6 +33,8 @@ export interface DataGridSaveStatementOptions {
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
deletedRows: number[];
newRows: GridCellValue[][];
/** `生成 SQL 时包含数据库名`: qualify `database.table` engines in the save SQL. */
includeDatabaseName?: boolean;
}
export interface DataGridCopyUpdateStatementOptions {
@@ -9,7 +9,7 @@ export interface JoinedSaveTarget {
/** Feed per-source changes through the existing SQL builder, including undo SQL. */
export function joinedSaveOptions(
targets: JoinedSaveTarget[],
options: Pick<DataGridSaveStatementOptions, "databaseType" | "identifierQuote" | "columns"> & {
options: Pick<DataGridSaveStatementOptions, "databaseType" | "identifierQuote" | "columns" | "includeDatabaseName"> & {
rows: CellValue[][];
dirtyRows: Map<number, Map<number, CellValue>>;
newRows: CellValue[][];
@@ -32,6 +32,7 @@ export function joinedSaveOptions(
dirtyRows: [[update.rowIndex, [...update.changes]]],
newRows: [],
deletedRows: [],
includeDatabaseName: options.includeDatabaseName,
}));
}
@@ -1,5 +1,5 @@
import { joinExportedDdls } from "@/lib/export/ddlExport";
import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import type { DatabaseType } from "@/types/database";
@@ -21,7 +21,7 @@ export async function buildSidebarDdlTemplateSql<T>(targets: readonly T[], loadD
return parts.length === 1 ? parts[0]! : joinExportedDdls(parts);
}
export function formatSidebarDdlTemplateForDisplay(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, quoteIdentifiers: boolean, catalog?: string): string {
const unqualified = omitDdlDatabaseQualifier(sql, dialect, databaseType, includeDatabaseName, catalog);
export function formatSidebarDdlTemplateForDisplay(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, database: string | undefined, quoteIdentifiers: boolean, catalog?: string): string {
const unqualified = applyDdlDatabaseQualifier(sql, dialect, databaseType, includeDatabaseName, database, catalog);
return quoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, dialect);
}
+52 -18
View File
@@ -1,56 +1,90 @@
import { describe, expect, it } from "vitest";
import { ddlFormatDialectFor, formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { applyDdlDatabaseQualifier, ddlFormatDialectFor, formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
describe("omitDdlDatabaseQualifier", () => {
describe("applyDdlDatabaseQualifier", () => {
it("drops the schema qualifier from Oracle table DDL without touching tablespace references", () => {
const ddl = 'CREATE TABLE "SYSTEM"."TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE TABLE "TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";');
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE TABLE "TEST" ("ABC" CLOB) TABLESPACE "SYSTEM";');
});
it("drops the qualifier from generated ALTER statements and keeps column references", () => {
const ddl = 'ALTER TABLE "SYSTEM"."TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "SYSTEM"."TEST"."CDE" IS \'note\';';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('ALTER TABLE "TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "TEST"."CDE" IS \'note\';');
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('ALTER TABLE "TEST" ADD ("CDE" CLOB); COMMENT ON COLUMN "TEST"."CDE" IS \'note\';');
});
it("drops the qualifier from PostgreSQL, MySQL, and Dameng table references", () => {
expect(omitDdlDatabaseQualifier('CREATE TABLE "public"."users" ("id" integer)', "postgres", "postgres", false)).toBe('CREATE TABLE "users" ("id" integer)');
expect(omitDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` int)", "mysql", "mysql", false)).toBe("CREATE TABLE `events` (`id` int)");
expect(omitDdlDatabaseQualifier('ALTER TABLE "APP"."T1" ADD ("C1" INT);', "dameng", "dameng", false)).toBe('ALTER TABLE "T1" ADD ("C1" INT);');
expect(applyDdlDatabaseQualifier('CREATE TABLE "public"."users" ("id" integer)', "postgres", "postgres", false)).toBe('CREATE TABLE "users" ("id" integer)');
expect(applyDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` int)", "mysql", "mysql", false)).toBe("CREATE TABLE `events` (`id` int)");
expect(applyDdlDatabaseQualifier('ALTER TABLE "APP"."T1" ADD ("C1" INT);', "dameng", "dameng", false)).toBe('ALTER TABLE "T1" ADD ("C1" INT);');
});
it("drops both qualifiers of CREATE INDEX ... ON and DROP INDEX targets", () => {
const ddl = 'CREATE INDEX "SYSTEM"."IDX_T" ON "SYSTEM"."TEST" ("ABC"); DROP INDEX "SYSTEM"."IDX_T";';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE INDEX "IDX_T" ON "TEST" ("ABC"); DROP INDEX "IDX_T";');
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe('CREATE INDEX "IDX_T" ON "TEST" ("ABC"); DROP INDEX "IDX_T";');
});
it("handles comma-separated DROP TABLE lists and TRUNCATE", () => {
expect(omitDdlDatabaseQualifier('DROP TABLE "public"."a", "public"."b";', "postgres", "postgres", false)).toBe('DROP TABLE "a", "b";');
expect(omitDdlDatabaseQualifier('TRUNCATE TABLE "public"."a";', "postgres", "postgres", false)).toBe('TRUNCATE TABLE "a";');
expect(applyDdlDatabaseQualifier('DROP TABLE "public"."a", "public"."b";', "postgres", "postgres", false)).toBe('DROP TABLE "a", "b";');
expect(applyDdlDatabaseQualifier('TRUNCATE TABLE "public"."a";', "postgres", "postgres", false)).toBe('TRUNCATE TABLE "a";');
});
it("keeps the qualifier when the preference is enabled, the dialect needs it, or the type is unknown", () => {
const ddl = 'ALTER TABLE "SYSTEM"."TEST" ADD ("CDE" CLOB);';
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", true)).toBe(ddl);
expect(omitDdlDatabaseQualifier("ALTER TABLE [dbo].[t] ADD [c] int;", "sqlserver", "sqlserver", false)).toBe("ALTER TABLE [dbo].[t] ADD [c] int;");
expect(omitDdlDatabaseQualifier(ddl, "oracle", undefined, false)).toBe(ddl);
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", true)).toBe(ddl);
expect(applyDdlDatabaseQualifier("ALTER TABLE [dbo].[t] ADD [c] int;", "sqlserver", "sqlserver", false)).toBe("ALTER TABLE [dbo].[t] ADD [c] int;");
expect(applyDdlDatabaseQualifier(ddl, "oracle", undefined, false)).toBe(ddl);
});
it("keeps Doris and StarRocks external-catalog qualifiers", () => {
const dorisDdl = "ALTER TABLE `iceberg`.`analytics`.`events` ADD COLUMN `source` STRING;";
const starrocksDdl = "CREATE TABLE `hive`.`events` (`id` bigint);";
expect(omitDdlDatabaseQualifier(dorisDdl, "mysql", "doris", false, "iceberg")).toBe(dorisDdl);
expect(omitDdlDatabaseQualifier(starrocksDdl, "mysql", "starrocks", false, "hive")).toBe(starrocksDdl);
expect(applyDdlDatabaseQualifier(dorisDdl, "mysql", "doris", false, undefined, "iceberg")).toBe(dorisDdl);
expect(applyDdlDatabaseQualifier(starrocksDdl, "mysql", "starrocks", false, undefined, "hive")).toBe(starrocksDdl);
expect(applyDdlDatabaseQualifier("ALTER TABLE `events` ADD COLUMN `source` STRING;", "mysql", "starrocks", true, "analytics", "hive")).toBe("ALTER TABLE `events` ADD COLUMN `source` STRING;");
});
it("drops Doris and StarRocks internal-catalog database qualifiers", () => {
expect(omitDdlDatabaseQualifier("ALTER TABLE `analytics`.`events` ADD COLUMN `source` STRING;", "mysql", "doris", false, "internal")).toBe("ALTER TABLE `events` ADD COLUMN `source` STRING;");
expect(omitDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` bigint);", "mysql", "starrocks", false, "internal")).toBe("CREATE TABLE `events` (`id` bigint);");
expect(applyDdlDatabaseQualifier("ALTER TABLE `analytics`.`events` ADD COLUMN `source` STRING;", "mysql", "doris", false, undefined, "internal")).toBe("ALTER TABLE `events` ADD COLUMN `source` STRING;");
expect(applyDdlDatabaseQualifier("CREATE TABLE `analytics`.`events` (`id` bigint);", "mysql", "starrocks", false, undefined, "internal")).toBe("CREATE TABLE `events` (`id` bigint);");
});
it("leaves statements it does not understand untouched", () => {
const ddl = "CREATE SEQUENCE SYSTEM.SEQ START WITH 1; SELECT SYSTEM.TEST.ID FROM SYSTEM.TEST;";
expect(omitDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe(ddl);
expect(applyDdlDatabaseQualifier(ddl, "oracle", "oracle", false)).toBe(ddl);
});
// issue #9262: with the preference on, the server never returns the database
// for MySQL (`SHOW CREATE TABLE` cannot include it) and SQL Server DDL is
// generated as `schema.table`, so the display layer has to add the segment.
it("inserts the database prefix into MySQL DDL when the preference is enabled", () => {
expect(applyDdlDatabaseQualifier("CREATE TABLE `test1` (`id` int NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) ENGINE=InnoDB;", "mysql", "mysql", true, "dbx")).toBe("CREATE TABLE `dbx`.`test1` (`id` int NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) ENGINE=InnoDB;");
expect(applyDdlDatabaseQualifier("ALTER TABLE `test1` ADD COLUMN `nick` varchar(20);", "mysql", "mysql", true, "dbx")).toBe("ALTER TABLE `dbx`.`test1` ADD COLUMN `nick` varchar(20);");
expect(applyDdlDatabaseQualifier("DROP TABLE `a`, `b`;", "mysql", "mysql", true, "dbx")).toBe("DROP TABLE `dbx`.`a`, `dbx`.`b`;");
expect(applyDdlDatabaseQualifier("CREATE INDEX `idx_t` ON `test1` (`name`);", "mysql", "mysql", true, "dbx")).toBe("CREATE INDEX `idx_t` ON `dbx`.`test1` (`name`);");
});
it("inserts the database prefix into SQL Server DDL when the preference is enabled", () => {
expect(applyDdlDatabaseQualifier("CREATE TABLE [dbo].[AcceptanceProductLog] ([uID] uniqueidentifier NOT NULL);", "sqlserver", "sqlserver", true, "dbx")).toBe("CREATE TABLE [dbx].[dbo].[AcceptanceProductLog] ([uID] uniqueidentifier NOT NULL);");
expect(applyDdlDatabaseQualifier("ALTER TABLE [dbo].[t] ADD [c] int;", "sqlserver", "sqlserver", true, "dbx")).toBe("ALTER TABLE [dbx].[dbo].[t] ADD [c] int;");
// The SQL Server DDL generator quotes names that need it.
expect(applyDdlDatabaseQualifier("CREATE TABLE [dbo].[player states] ([role id] int);", "sqlserver", "sqlserver", true, "db x")).toBe("CREATE TABLE [db x].[dbo].[player states] ([role id] int);");
});
it("does not double-qualify DDL that already carries the database", () => {
const mysqlDdl = "CREATE TABLE `dbx`.`test1` (`id` int);";
expect(applyDdlDatabaseQualifier(mysqlDdl, "mysql", "mysql", true, "dbx")).toBe(mysqlDdl);
const sqlserverDdl = "CREATE TABLE [dbx].[dbo].[t] ([id] int);";
expect(applyDdlDatabaseQualifier(sqlserverDdl, "sqlserver", "sqlserver", true, "dbx")).toBe(sqlserverDdl);
});
it("only completes names whose remaining shape can still be valid", () => {
// SQL Server without the schema segment cannot become `database.schema.table`.
expect(applyDdlDatabaseQualifier("CREATE TABLE [t] ([id] int);", "sqlserver", "sqlserver", true, "dbx")).toBe("CREATE TABLE [t] ([id] int);");
// Engines that never take a leading database segment stay untouched.
expect(applyDdlDatabaseQualifier('CREATE TABLE "public"."users" ("id" integer);', "postgres", "postgres", true, "dbx")).toBe('CREATE TABLE "public"."users" ("id" integer);');
// No database name resolved — nothing to insert.
expect(applyDdlDatabaseQualifier("CREATE TABLE `test1` (`id` int);", "mysql", "mysql", true, undefined)).toBe("CREATE TABLE `test1` (`id` int);");
});
});
+114 -22
View File
@@ -2,7 +2,7 @@ import { requiresDamengIdentifierQuote, requiresMysqlIdentifierQuote, requiresOr
import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
import type { SqlSemanticToken } from "@/lib/sql/semantic/types";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { dropsSchemaQualifier } from "@/lib/table/tableSelectSql";
import { dropsSchemaQualifier, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import type { DatabaseType } from "@/types/database";
const SIMPLE_SQLSERVER_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -153,7 +153,24 @@ function collectDdlQualifierRemoval(tokens: readonly SqlSemanticToken[], parts:
out.push({ start: tokens[parts[0]!]!.span.start, end: tokens[parts[firstKept]!]!.span.start });
}
function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[], from: number, to: number, out: DdlSpan[]): void {
interface DdlNameTarget {
/** Token indexes of every identifier part of the qualified name. */
parts: number[];
/**
* Trailing parts that must survive a rewrite. `COMMENT ON COLUMN t.c` keeps
* two (`t.c`), everything else keeps the bare object name.
*/
keep: number;
/**
* Whether a leading database segment may be *inserted*. Index names are
* scoped to their table (`CREATE INDEX db.idx` is invalid on MySQL and
* SQL Server), so they stay untouched when the preference is enabled.
*/
qualifiable: boolean;
}
/** Every qualified name a DDL statement addresses, in source order. */
function collectDdlStatementNameTargets(tokens: readonly SqlSemanticToken[], from: number, to: number, out: DdlNameTarget[]): void {
let index = from;
while (index < to && tokens[index]!.kind === "comment") index += 1;
const head = tokens[index];
@@ -173,12 +190,12 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
cursor = skipDdlWords(tokens, cursor + 1, to, ["if", "not", "exists"]);
const nameParts = readDdlQualifiedName(tokens, cursor, to);
if (!nameParts) return;
collectDdlQualifierRemoval(tokens, nameParts, 1, out);
out.push({ parts: nameParts, keep: 1, qualifiable: objectWord.normalized !== "index" });
if (objectWord.normalized === "index") {
const onIndex = findDdlKeyword(tokens, nameParts[nameParts.length - 1]! + 1, to, "on");
if (onIndex < 0) return;
const tableParts = readDdlQualifiedName(tokens, onIndex + 1, to);
if (tableParts) collectDdlQualifierRemoval(tokens, tableParts, 1, out);
if (tableParts) out.push({ parts: tableParts, keep: 1, qualifiable: true });
}
return;
}
@@ -187,7 +204,7 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
const objectWord = tokens[index + 1];
if (!objectWord || objectWord.kind !== "word" || !DDL_ALTER_OBJECT_WORDS.has(objectWord.normalized)) return;
const nameParts = readDdlQualifiedName(tokens, skipDdlWords(tokens, index + 2, to, ["if", "exists"]), to);
if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, 1, out);
if (nameParts) out.push({ parts: nameParts, keep: 1, qualifiable: objectWord.normalized !== "index" });
return;
}
@@ -198,7 +215,7 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
for (;;) {
const nameParts = readDdlQualifiedName(tokens, cursor, to);
if (!nameParts) return;
collectDdlQualifierRemoval(tokens, nameParts, 1, out);
out.push({ parts: nameParts, keep: 1, qualifiable: objectWord.normalized !== "index" });
cursor = nameParts[nameParts.length - 1]! + 1;
if (tokens[cursor]?.text !== ",") return;
cursor += 1;
@@ -209,7 +226,7 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
let cursor = index + 1;
if (tokens[cursor]?.kind === "word" && ["table", "only"].includes(tokens[cursor]!.normalized)) cursor += 1;
const nameParts = readDdlQualifiedName(tokens, cursor, to);
if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, 1, out);
if (nameParts) out.push({ parts: nameParts, keep: 1, qualifiable: true });
return;
}
@@ -221,19 +238,100 @@ function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[
if (!isColumn && !DDL_COMMENT_OBJECT_WORDS.has(objectWord.normalized)) return;
const nameParts = readDdlQualifiedName(tokens, index + 3, to);
// `COMMENT ON COLUMN t.c` keeps `t.c`, so only the schema qualifier goes.
if (nameParts) collectDdlQualifierRemoval(tokens, nameParts, isColumn ? 2 : 1, out);
if (nameParts) out.push({ parts: nameParts, keep: isColumn ? 2 : 1, qualifiable: false });
}
}
/** Every qualified name addressed by the DDL statements in `sql`, in source order. */
function ddlStatementNameTargets(sql: string, dialect: SqlFormatDialect): { targets: DdlNameTarget[]; tokens: readonly SqlSemanticToken[] } | undefined {
const tokens = tokenizeSqlSemantic(sql, dialect === "sqlserver" ? "sqlserver" : dialect);
if (tokens.some((token) => token.closed === false)) return undefined;
const targets: DdlNameTarget[] = [];
let statementStart = 0;
for (let index = 0; index <= tokens.length; index += 1) {
const token = tokens[index];
if (token && !(token.kind === "punctuation" && token.text === ";" && token.depth === 0)) continue;
collectDdlStatementNameTargets(tokens, statementStart, index, targets);
statementStart = index + 1;
}
return { targets, tokens };
}
function collectDdlStatementQualifierRemovals(tokens: readonly SqlSemanticToken[], from: number, to: number, out: DdlSpan[]): void {
const targets: DdlNameTarget[] = [];
collectDdlStatementNameTargets(tokens, from, to, targets);
for (const target of targets) collectDdlQualifierRemoval(tokens, target.parts, target.keep, out);
}
function applyDdlSpans(sql: string, spans: readonly { start: number; end: number; text?: string }[]): string {
const ordered = [...spans].sort((left, right) => left.start - right.start);
let result = sql;
for (let index = ordered.length - 1; index >= 0; index -= 1) {
const span = ordered[index]!;
result = `${result.slice(0, span.start)}${span.text ?? ""}${result.slice(span.end)}`;
}
return result;
}
/** Engines whose fully qualified table name is `database.table`. */
const DDL_DATABASE_PREFIXED_TYPES = new Set<DatabaseType>(["mysql", "goldendb", "clickhouse"]);
/** Engines whose fully qualified table name is `database.schema.table`. */
const DDL_DATABASE_SCHEMA_PREFIXED_TYPES = new Set<DatabaseType>(["sqlserver"]);
/**
* Drops the schema/database qualifier from the target object of DDL statements
* (`CREATE/ALTER/DROP TABLE`, `CREATE INDEX ... ON`, `COMMENT ON ...`,
* `TRUNCATE TABLE`) so table DDL honors the "Include database name in generated
* SQL" preference the same way generated SELECT and copy-as-INSERT SQL do
* (#9421). Databases whose objects cannot be addressed without their qualifier
* keep it, mirroring `dropsSchemaQualifier`.
* Number of parts a fully qualified target name has on this engine, or
* `undefined` when the engine cannot take a leading database segment (then the
* DDL stays untouched in both directions).
*/
export function omitDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, catalog?: string): string {
function ddlQualifiedPartCount(databaseType: DatabaseType | undefined, catalog?: string): number | undefined {
if (databaseType === undefined) return undefined;
if (DDL_DATABASE_SCHEMA_PREFIXED_TYPES.has(databaseType)) return 3;
if (DDL_DATABASE_PREFIXED_TYPES.has(databaseType)) return 2;
if (databaseType === "doris" || databaseType === "starrocks") {
// An external catalog already supplies the leading segment
// (`catalog.database.table`), so the DDL must not grow a second one.
const external = catalog?.trim();
return !external || external === "internal" ? 2 : undefined;
}
return undefined;
}
/**
* Applies the "Include database name in generated SQL" preference to the target
* object of DDL statements (`CREATE/ALTER/DROP TABLE`, `CREATE INDEX ... ON`,
* `COMMENT ON ...`, `TRUNCATE TABLE`), the same way generated SELECT and
* copy-as-INSERT SQL honor it.
*
* With the preference off the schema/database qualifier is dropped (#9421).
* With it on, engines whose server never returns the database — MySQL's
* `SHOW CREATE TABLE` cannot include it — and SQL Server, whose generated DDL
* stops at `schema.table`, get the leading `database` segment inserted, so a
* SQL Server table reads `[dbx].[dbo].[t]` (#9262). Databases whose objects
* cannot be addressed without their qualifier keep it, mirroring
* `dropsSchemaQualifier`.
*/
export function applyDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, database?: string, catalog?: string): string {
if (includeDatabaseName) {
const qualifiedParts = ddlQualifiedPartCount(databaseType, catalog);
const databaseName = database?.trim();
if (qualifiedParts === undefined || !databaseName) return sql;
const parsed = ddlStatementNameTargets(sql, dialect);
if (!parsed) return sql;
const prefix = `${quoteTableIdentifier(databaseType, databaseName)}.`;
const insertions: Array<DdlSpan & { text: string }> = [];
for (const target of parsed.targets) {
// Only a table-like target can be completed: adding a segment to a bare
// or half-qualified name would invent a namespace the DDL never named,
// and a `COMMENT ON COLUMN t.c` target is not a database-addressable
// object.
if (!target.qualifiable || target.keep !== 1 || target.parts.length !== qualifiedParts - 1) continue;
const at = parsed.tokens[target.parts[0]!]!.span.start;
insertions.push({ start: at, end: at, text: prefix });
}
if (insertions.length === 0) return sql;
return applyDdlSpans(sql, insertions);
}
if (!dropsSchemaQualifier(databaseType, includeDatabaseName, catalog)) return sql;
const tokens = tokenizeSqlSemantic(sql, dialect === "sqlserver" ? "sqlserver" : dialect);
if (tokens.some((token) => token.closed === false)) return sql;
@@ -248,11 +346,5 @@ export function omitDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect,
}
if (removals.length === 0) return sql;
removals.sort((left, right) => left.start - right.start);
let result = sql;
for (let index = removals.length - 1; index >= 0; index -= 1) {
const removal = removals[index]!;
result = `${result.slice(0, removal.start)}${result.slice(removal.end)}`;
}
return result;
return applyDdlSpans(sql, removals);
}
+62 -20
View File
@@ -40,6 +40,11 @@ export interface BuildTableSelectSqlOptions {
const DATABASE_QUALIFIED_TABLE_TYPES = new Set<DatabaseType>(["mysql", "clickhouse", "doris", "starrocks", "goldendb"]);
// SQL Server is the one engine whose generated SQL needs both namespaces at
// once: `database.schema.table`. The other engines above address a table with a
// single additional segment (`database.table`).
const DATABASE_SCHEMA_PREFIXED_TABLE_TYPES = new Set<DatabaseType>(["sqlserver"]);
// `includeDatabaseName === false` drops the schema qualifier — the "database
// name" on schema-aware engines — except for databases that can only address
// objects through their full qualified name (`catalog.schema.table` /
@@ -257,6 +262,14 @@ export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "da
if (linked) {
return quoteIdentifiers === false ? [linked.server, linked.catalog, linked.schema, tableName].map((name) => quoteTableIdentifierIfNeeded(databaseType, name)).join(".") : sqlServerLinkedTableName(linked, tableName);
}
// issue #9262: SQL Server can address every table on the connection as
// `database.schema.table`, and the setting opts that three-part form in.
// A linked-server schema already carries `server|catalog|schema`, so it
// must never gain the local database on top.
const trimmedDatabase = includeDatabaseName ? database?.trim() : undefined;
if (trimmedDatabase) {
return `${quoteTable(trimmedDatabase)}.${quoteTable(schema)}.${quoteTable(tableName)}`;
}
}
// The schema qualifier is the "database name" on schema-aware engines
// (Oracle's SYSTEM, PG's public, ...). `dropsSchemaQualifier` keeps it
@@ -328,20 +341,47 @@ function sqlCteVisibilities(tokens: readonly SqlSemanticToken[], sqlLength: numb
return visibilities;
}
/**
* Counts the qualifier segments a table name already carries and reports where a
* database prefix has to be inserted. `sqlSemanticTableNameSpans` only exposes
* the final segment, so the qualifier chain is rebuilt from the surrounding
* tokens (`[dbo].[t]` -> one segment starting at `[dbo]`).
*/
function sqlTableNameQualifier(tokens: readonly SqlSemanticToken[], index: number): { parts: number; start: number } {
let parts = 0;
let cursor = index;
let start = tokens[index]?.span.start ?? 0;
while (cursor >= 2 && tokens[cursor - 1]?.text === "." && tokenIsIdentifier(tokens[cursor - 2])) {
cursor -= 2;
parts += 1;
start = tokens[cursor]?.span.start ?? start;
}
return { parts, start };
}
/**
* 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.
*
* MySQL-family engines have the active database inserted in front of a one-part
* table name. SQL Server instead keeps the schema it already names and gains the
* database in front of it (`[dbo].[t]` -> `[db].[dbo].[t]`), because
* `db.table` is not a valid SQL Server reference. Names that omit the schema or
* already carry a database stay untouched.
*/
export function qualifyTableReferencesInSql(sql: string, options: Pick<BuildTableSelectSqlOptions, "databaseType" | "database" | "includeDatabaseName">): 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());
const databaseType = options.databaseType;
if (!options.includeDatabaseName || !databaseType || !options.database?.trim()) return sql;
const schemaPrefixed = DATABASE_SCHEMA_PREFIXED_TABLE_TYPES.has(databaseType);
if (!schemaPrefixed && !DATABASE_QUALIFIED_TABLE_TYPES.has(databaseType)) return sql;
const database = quoteTableIdentifier(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.
// points at the original source text. CTEs and already-qualified tables never
// gain a prefix.
const semanticOptions = {
databaseType: options.databaseType,
dialect: options.databaseType === "goldendb" ? "mysql" : undefined,
databaseType,
dialect: databaseType === "goldendb" ? "mysql" : undefined,
} as const;
const dialectId = sqlSemanticDialectFor(semanticOptions).id;
const replacements = sqlStatementSpans(sql, dialectId)
@@ -350,25 +390,27 @@ export function qualifyTableReferencesInSql(sql: string, options: Pick<BuildTabl
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]));
const tokenIndexBySpan = new Map(tokens.map((token, index) => [`${token.span.start}:${token.span.end}`, index]));
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) => ({ span, index: tokenIndexBySpan.get(`${span.start}:${span.end}`) }))
.filter(({ span, index }) => {
if (index === undefined || isCteReference(unquoteSqlSemanticIdentifier(tokens[index]!), span)) return false;
return schemaPrefixed ? sqlTableNameQualifier(tokens, index).parts === 1 : sqlTableNameQualifier(tokens, index).parts === 0;
})
.map(({ span, token }) => ({
span: { start: start + span.start, end: start + span.end },
tableName: unquoteSqlSemanticIdentifier(token!),
}));
.map(({ span, index }) => {
const tableName = unquoteSqlSemanticIdentifier(tokens[index!]!);
if (schemaPrefixed) {
const qualifier = sqlTableNameQualifier(tokens, index!);
return { start: start + qualifier.start, end: start + qualifier.start, replacement: `${database}.` };
}
return { start: start + span.start, end: start + span.end, replacement: `${database}.${quoteTableIdentifier(databaseType, tableName)}` };
});
})
.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);
.filter(({ start }, index, all) => all.findIndex((candidate) => candidate.start === start) === index)
.sort((left, right) => right.start - left.start);
return replacements.reduce((qualifiedSql, { span, tableName }) => `${qualifiedSql.slice(0, span.start)}${database}.${quoteTableIdentifier(options.databaseType, tableName)}${qualifiedSql.slice(span.end)}`, sql);
return replacements.reduce((qualifiedSql, { start, end, replacement }) => `${qualifiedSql.slice(0, start)}${replacement}${qualifiedSql.slice(end)}`, sql);
}
export function metricSelector(metricName: string): string {
@@ -86,6 +86,7 @@ fn mysql_cross_database_query_flow_preserves_target_database() {
dirty_rows: vec![(0, vec![(1, json!("after"))])],
deleted_rows: vec![],
new_rows: vec![],
include_database_name: false,
});
assert_eq!(save.execution_schema.as_deref(), Some("db_9"));
@@ -5,7 +5,7 @@ use super::{
};
use crate::data_grid_sql::{
build_column_predicate, build_data_grid_copy_insert_statement, build_data_grid_copy_update_statements,
data_grid_qualified_table_name, format_grid_sql_literal_with_identifier_quote, is_auto_generated_column,
data_grid_generated_table_name, format_grid_sql_literal_with_identifier_quote, is_auto_generated_column,
is_grid_insert_omitted_column, is_non_identity_generated_column, supports_relational_copy_predicates,
DataGridCopyInsertStatementOptions, DataGridCopyUpdateStatementOptions, DataGridTableMeta,
};
@@ -121,6 +121,7 @@ pub(super) fn write_sql_updates(
columns: data.columns,
source_columns: Some(data.source_columns),
rows: data.rows,
include_database_name: context.request.options.sql.include_database_name,
});
if statements.len() != context.request.rows.len() {
return Err(DataGridExtractError::new(
@@ -371,13 +372,14 @@ pub(super) fn write_sql_select(
));
}
let table = data_grid_qualified_table_name(
let table = data_grid_generated_table_name(
context.request.database_type,
table_meta.catalog.as_deref(),
table_meta.schema.as_deref(),
table_meta.database.as_deref(),
&table_meta.table_name,
context.request.identifier_quote.as_deref(),
context.request.options.sql.include_database_name,
);
if context.request.selection_kind == super::DataGridSelectionKind::Cells {
@@ -934,6 +934,7 @@ fn build_data_grid_copy_update_statements_returns_empty_for_mongodb() {
columns: vec!["id".to_string()],
source_columns: Some(vec![Some("id".to_string())]),
rows: vec![vec![json!(1)]],
include_database_name: false,
};
assert!(build_data_grid_copy_update_statements(options).is_empty());
}
@@ -268,6 +268,7 @@ mod tests {
dirty_rows: Vec::new(),
deleted_rows: Vec::new(),
new_rows: Vec::new(),
include_database_name: false,
}
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -47,7 +47,8 @@ pub use table_select::{
build_table_select_sql, DBX_LARGE_VALUE_BYTES_COLUMN_PREFIX,
};
pub use table_select::{
quote_table_data_identifier, table_data_qualified_table_name, table_data_schema, uses_connection_identifier_quote,
database_qualified_table_name, quote_table_data_identifier, table_data_qualified_table_name, table_data_schema,
uses_connection_identifier_quote,
};
pub use type_rewrite::{
apply_auto_inc_to_column_def, column_is_auto_increment, normalize_len_params, rewrite_column_type,
+115 -7
View File
@@ -4,8 +4,8 @@ use super::capabilities::{
firebird_rows_clause, table_pagination_strategy, uses_oracle_row_id, uses_xugu_row_id, TablePaginationStrategy,
};
use super::identifiers::{
normalize_where_input, qualified_table_name, qualified_table_name_with_catalog, quote_gaussdb_jdbc_identifier,
quote_iris_identifier, quote_table_identifier,
normalize_where_input, parse_sqlserver_linked_schema_ref, qualified_table_name, qualified_table_name_with_catalog,
quote_gaussdb_jdbc_identifier, quote_iris_identifier, quote_table_identifier,
};
use super::types::{
TableDataSelectSqlOptions, TableSelectSqlOptions, DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN,
@@ -221,6 +221,7 @@ pub fn build_table_data_select_sql_with_database(
database_qualified_table_name(
database_type,
options.catalog.as_deref(),
schema,
options.database.as_deref(),
&options.table_name,
)
@@ -438,12 +439,26 @@ fn default_time_series_predicate(database_type: Option<DatabaseType>) -> Option<
}
}
/// 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(
/// Returns the fully qualified reference for engines whose active database is
/// normally omitted from generated table SQL:
///
/// - `database.table` for MySQL-compatible engines and ClickHouse;
/// - `database.schema.table` for SQL Server, whose tables are addressable
/// across databases on the same connection;
/// - Doris and StarRocks keep their external catalog prefix
/// (`catalog.database.table`).
///
/// Shared by every "generated table SQL" surface that honors the
/// `生成 SQL 时包含数据库名` setting, so the grid label, the copy-as-INSERT/UPDATE/
/// SELECT statements and the data-grid save statements stay in sync.
///
/// `schema` wins over `database` for the MySQL family: after a cross-database
/// editable result (`SELECT * FROM db_9.users`) the table's own namespace lives
/// in `schema` while `database` still holds the connection's default database.
pub fn database_qualified_table_name(
database_type: Option<DatabaseType>,
catalog: Option<&str>,
schema: Option<&str>,
database: Option<&str>,
table_name: &str,
) -> Option<String> {
@@ -455,7 +470,28 @@ fn database_qualified_table_name(
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))
let namespace = schema.map(str::trim).filter(|schema| !schema.is_empty()).unwrap_or(database);
Some(qualified_table_name_with_catalog(
database_type,
catalog,
Some(namespace),
Some(namespace),
table_name,
))
}
Some(DatabaseType::SqlServer) => {
// A linked-server schema already carries `server|catalog|schema`, so
// prefixing it with the local database would produce a bogus name.
let schema = schema.map(str::trim).filter(|schema| !schema.is_empty())?;
if parse_sqlserver_linked_schema_ref(schema).is_some() {
return None;
}
Some(format!(
"{}.{}.{}",
quote_table_identifier(database_type, database),
quote_table_identifier(database_type, schema),
quote_table_identifier(database_type, table_name)
))
}
_ => None,
}
@@ -1011,6 +1047,78 @@ mod tests {
assert_eq!(build_table_data_select_sql_with_database(options, true), "SELECT * FROM `aaa`.`apis` LIMIT 10;");
}
/// issue #9262: SQL Server addresses tables as `database.schema.table`, and
/// the grid label must reach the three-part form once the user opted into
/// `生成 SQL 时包含数据库名`.
#[test]
fn sqlserver_table_data_select_optionally_qualifies_database() {
let options = TableDataSelectSqlOptions {
database_type: Some(DatabaseType::SqlServer),
schema: Some("dbo".to_string()),
database: Some("dbx".to_string()),
table_name: "AcceptanceProductLog".to_string(),
limit: Some(100),
..Default::default()
};
assert_eq!(
build_table_data_select_sql(options.clone()),
"SELECT TOP (100) * FROM [dbo].[AcceptanceProductLog]"
);
assert_eq!(
build_table_data_select_sql_with_database(options, true),
"SELECT TOP (100) * FROM [dbx].[dbo].[AcceptanceProductLog]"
);
}
/// A linked-server schema already encodes `server|catalog|schema`; the local
/// database must never be prefixed on top of it.
#[test]
fn sqlserver_linked_schema_ignores_include_database_name() {
let options = TableDataSelectSqlOptions {
database_type: Some(DatabaseType::SqlServer),
schema: Some("__dbx_sqlserver_linked__:ERP|Finance|dbo".to_string()),
database: Some("dbx".to_string()),
table_name: "orders".to_string(),
limit: Some(100),
..Default::default()
};
let sql = build_table_data_select_sql_with_database(options, true);
assert_eq!(sql, "SELECT TOP (100) * FROM [ERP].[Finance].[dbo].[orders]");
}
/// `database_qualified_table_name` is shared by every generated-SQL surface,
/// so pin its per-engine contract directly.
#[test]
fn database_qualified_table_name_matches_engine_naming() {
assert_eq!(
database_qualified_table_name(Some(DatabaseType::Mysql), None, None, Some("dbx"), "t").as_deref(),
Some("`dbx`.`t`")
);
// A cross-database editable result keeps its own namespace, not the
// connection's default database.
assert_eq!(
database_qualified_table_name(Some(DatabaseType::Mysql), None, Some("db_9"), Some("dbx"), "t").as_deref(),
Some("`db_9`.`t`")
);
assert_eq!(
database_qualified_table_name(Some(DatabaseType::ClickHouse), None, Some("default"), Some("dbx"), "t")
.as_deref(),
Some("`dbx`.`t`")
);
assert_eq!(
database_qualified_table_name(Some(DatabaseType::SqlServer), None, Some("dbo"), Some("dbx"), "t")
.as_deref(),
Some("[dbx].[dbo].[t]")
);
// SQL Server without a schema cannot build a valid three-part name.
assert_eq!(database_qualified_table_name(Some(DatabaseType::SqlServer), None, None, Some("dbx"), "t"), None);
assert_eq!(
database_qualified_table_name(Some(DatabaseType::Postgres), None, Some("public"), Some("dbx"), "t"),
None
);
assert_eq!(database_qualified_table_name(Some(DatabaseType::Mysql), None, None, None, "t"), None);
}
#[test]
fn doris_external_catalog_without_database_degrades_to_two_part() {
// When neither schema nor database is provided the name degrades to the
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB