mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(ddl): honor database qualification settings
This commit is contained in:
@@ -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 { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { omitDdlDatabaseQualifier, 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";
|
||||
@@ -3515,7 +3515,8 @@ 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));
|
||||
sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect);
|
||||
const unqualified = omitDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
|
||||
sqlContent = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[DBX] Failed to load table DDL for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
|
||||
@@ -3548,6 +3549,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);
|
||||
if (!settingsStore.editorSettings.generateSqlQuoteIdentifiers) sqlContent = omitDdlIdentifierQuotes(sqlContent, formatDialect);
|
||||
metadataLoadFailed = false;
|
||||
}
|
||||
|
||||
@@ -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 { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { omitDdlDatabaseQualifier, 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,8 @@ async function loadDdl(force = false) {
|
||||
);
|
||||
const formatDialect = props.formatDialect ?? props.dialect;
|
||||
const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter);
|
||||
rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect);
|
||||
const unqualified = omitDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
|
||||
rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
|
||||
} catch (e: any) {
|
||||
ddlError.value = e?.message || String(e);
|
||||
} finally {
|
||||
|
||||
@@ -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 { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
@@ -1189,7 +1189,8 @@ async function fetchTableDdl(force = settingsStore.editorSettings.refreshDdlOnOp
|
||||
const { ddl } = await loadObjectDdl(tableMetadataRequest(row), { force });
|
||||
if (sidePanelGuard.isStale(epoch)) return;
|
||||
const formatDialect = sqlFormatDialectForDbType(effectiveDatabaseType.value);
|
||||
rawTableDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? ddl : omitDdlIdentifierQuotes(ddl, formatDialect);
|
||||
const unqualified = omitDdlDatabaseQualifier(ddl, formatDialect, effectiveDatabaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.catalog);
|
||||
rawTableDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
|
||||
loadedSuccessfully = true;
|
||||
} catch (e: any) {
|
||||
if (sidePanelGuard.isStale(epoch)) return;
|
||||
|
||||
@@ -160,7 +160,6 @@ import { buildRenameObjectSql, buildRenameDatabaseSql, buildRenameDatabasePrefli
|
||||
import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
|
||||
import { buildViewDdl } from "@/lib/table/viewDdl";
|
||||
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
|
||||
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
|
||||
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionTableSqlSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { isObjectCacheInvalidationError } from "@/lib/metadata/objectCacheInvalidationError";
|
||||
@@ -190,7 +189,7 @@ import { sidebarTreeArrowAction } from "@/lib/sidebar/sidebarTreeArrowNavigation
|
||||
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
|
||||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||||
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
|
||||
import { buildSidebarDdlTemplateSql } from "@/lib/sidebar/sidebarDdlTemplate";
|
||||
import { buildSidebarDdlTemplateSql, formatSidebarDdlTemplateForDisplay } from "@/lib/sidebar/sidebarDdlTemplate";
|
||||
import { resolveSidebarDdlTargets } from "@/lib/sidebar/sidebarDdlTargets";
|
||||
import { sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
|
||||
import { formatSidebarTableCopyText, type FormatSidebarTableNamesOptions } from "@/lib/sidebar/sidebarTableNameCopy";
|
||||
@@ -2152,7 +2151,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 settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, formatDialect);
|
||||
return formatSidebarDdlTemplateForDisplay(formatted, formatDialect, databaseTypeForNode(target), settingsStore.editorSettings.generateSqlIncludeDatabaseName, settingsStore.editorSettings.generateSqlQuoteIdentifiers, target.catalog);
|
||||
},
|
||||
);
|
||||
connectionStore.activeConnectionId = tabTarget.connectionId;
|
||||
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const runtimeSource = readFileSync(fileURLToPath(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url)), "utf8");
|
||||
|
||||
function asyncFunctionSource(name: string): string {
|
||||
const start = runtimeSource.indexOf(`async function ${name}(`);
|
||||
expect(start, `${name} not found`).toBeGreaterThan(-1);
|
||||
const next = runtimeSource.indexOf("\nasync function ", start + 1);
|
||||
return runtimeSource.slice(start, next === -1 ? undefined : next);
|
||||
}
|
||||
|
||||
describe("SidebarTreeRuntimeHost DDL database qualifier setting (#9421)", () => {
|
||||
it("applies the setting with each target catalog before identifier quote formatting", () => {
|
||||
const openDdl = asyncFunctionSource("openSidebarMultiTableDdlTab");
|
||||
const formatterCall = openDdl.indexOf("formatSidebarDdlTemplateForDisplay(");
|
||||
|
||||
expect(formatterCall).toBeGreaterThan(-1);
|
||||
expect(openDdl.slice(formatterCall, openDdl.indexOf(");", formatterCall))).toContain("target.catalog");
|
||||
});
|
||||
});
|
||||
@@ -30,10 +30,10 @@ import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter";
|
||||
import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript";
|
||||
import { formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
|
||||
import { formatSqlForDisplay, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
@@ -341,6 +341,17 @@ function scheduleDdlEditorInit() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the DDL display preferences (database qualifier + identifier quoting)
|
||||
* to DDL shown by the structure editor, whether loaded from the server or
|
||||
* 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);
|
||||
if (settingsStore.editorSettings.generateSqlQuoteIdentifiers) return unqualified;
|
||||
return generated ? formatGeneratedDdlIdentifierQuotes(unqualified, dialect, false) : omitDdlIdentifierQuotes(unqualified, dialect);
|
||||
}
|
||||
|
||||
function ddlRequest() {
|
||||
return {
|
||||
connectionId: props.connectionId,
|
||||
@@ -359,7 +370,7 @@ async function fetchDdl(force = false) {
|
||||
const { ddl } = await loadObjectDdl(ddlRequest(), { force });
|
||||
const dialect = sqlFormatDialectForDbType(databaseType.value);
|
||||
const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect);
|
||||
rawDdlContent.value = formatDdlForDisplay(formatted, dialect);
|
||||
ddlFetched.value = true;
|
||||
} catch (e: any) {
|
||||
rawDdlContent.value = `-- Error: ${e?.message || e}`;
|
||||
@@ -1518,7 +1529,7 @@ async function hydrateRestoredDraftFromDatabase() {
|
||||
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog });
|
||||
const dialect = sqlFormatDialectForDbType(databaseType.value);
|
||||
const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect);
|
||||
rawDdlContent.value = formatDdlForDisplay(formatted, dialect);
|
||||
ddlFetched.value = true;
|
||||
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
|
||||
} catch {
|
||||
@@ -1785,7 +1796,7 @@ async function refreshSqlPreview() {
|
||||
if (requestId !== sqlPreviewRequestId) return;
|
||||
const statements = [...result.statements, ...ownerResult.statements, ...(mysqlAutoIncrementStatement ? [mysqlAutoIncrementStatement] : [])];
|
||||
// SQLite type-change apply regenerates this revision-checked plan, so its preview must stay byte-for-byte aligned.
|
||||
pendingStatements.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers !== false || hasSqliteTypeChange.value ? statements : statements.map((statement) => formatGeneratedDdlIdentifierQuotes(statement, sqlFormatDialectForDbType(databaseType.value), false));
|
||||
pendingStatements.value = hasSqliteTypeChange.value ? statements : statements.map((statement) => formatDdlForDisplay(statement, sqlFormatDialectForDbType(databaseType.value), true));
|
||||
warnings.value = [...result.warnings, ...ownerResult.warnings];
|
||||
sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined;
|
||||
} catch (e: any) {
|
||||
@@ -2178,7 +2189,7 @@ async function loadStructure(
|
||||
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }, { force: options.forceDdl });
|
||||
const dialect = sqlFormatDialectForDbType(databaseType.value);
|
||||
const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? formatted : omitDdlIdentifierQuotes(formatted, dialect);
|
||||
rawDdlContent.value = formatDdlForDisplay(formatted, dialect);
|
||||
ddlFetched.value = true;
|
||||
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
|
||||
} catch {
|
||||
|
||||
+33
-2
@@ -23,6 +23,16 @@ const mocks = vi.hoisted(() => ({
|
||||
getTablePartitionStatus: vi.fn(),
|
||||
getTableOwner: vi.fn(),
|
||||
updateEditorSettings: vi.fn(),
|
||||
editorSettings: {
|
||||
structureEditorDensity: "compact",
|
||||
sqlFormatter: {},
|
||||
tableColumnTemplateFields: [],
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
theme: "default",
|
||||
generateSqlIncludeDatabaseName: true,
|
||||
generateSqlQuoteIdentifiers: true,
|
||||
},
|
||||
loadObjectDdl: vi.fn(),
|
||||
invalidateObjectDdl: vi.fn(),
|
||||
loadObjectMetadataFacet: vi.fn(),
|
||||
@@ -259,7 +269,7 @@ vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRe
|
||||
vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], fontSize: 13, fontFamily: "monospace", theme: "default", generateSqlQuoteIdentifiers: true },
|
||||
editorSettings: mocks.editorSettings,
|
||||
updateEditorSettings: mocks.updateEditorSettings,
|
||||
}),
|
||||
}));
|
||||
@@ -293,7 +303,7 @@ import TableStructureEditor from "@/components/structure/TableStructureEditor.vu
|
||||
const mountedApps: App[] = [];
|
||||
let lastDraft: Record<string, unknown> | undefined;
|
||||
|
||||
async function mountStructureEditor() {
|
||||
async function mountStructureEditor(props: Record<string, unknown> = {}) {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp(TableStructureEditor, {
|
||||
@@ -301,6 +311,7 @@ async function mountStructureEditor() {
|
||||
database: "test",
|
||||
tableName: "users",
|
||||
initialTab: "columns",
|
||||
...props,
|
||||
"onUpdate:draft": (draft: Record<string, unknown> | undefined) => {
|
||||
lastDraft = draft;
|
||||
},
|
||||
@@ -373,6 +384,8 @@ async function editDdl(root: HTMLElement, script: string) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
lastDraft = undefined;
|
||||
mocks.connection.db_type = "mysql";
|
||||
mocks.editorSettings.generateSqlIncludeDatabaseName = true;
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] });
|
||||
mocks.executeBatch.mockResolvedValue({ rowsAffected: 0 });
|
||||
@@ -437,6 +450,24 @@ describe("TableStructureEditor DDL tab", () => {
|
||||
expect(mocks.buildTableStructureChangeSql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes generated external-catalog DDL without dropping qualifiers", async () => {
|
||||
mocks.connection.db_type = "doris";
|
||||
mocks.editorSettings.generateSqlIncludeDatabaseName = false;
|
||||
mocks.buildTableStructureChangeSql.mockResolvedValue({
|
||||
statements: ["ALTER TABLE `iceberg`.`analytics`.`users` ADD COLUMN `nickname` STRING;"],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const root = await mountStructureEditor({ database: "analytics", catalog: "iceberg" });
|
||||
buttonWithText(root, "structureEditor.addColumn").click();
|
||||
|
||||
await vi.waitFor(() => expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false), { timeout: 3000 });
|
||||
buttonWithText(root, "structureEditor.apply").click();
|
||||
|
||||
await vi.waitFor(() => expect(mocks.executeBatch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(mocks.executeBatch.mock.calls[0][2]).toEqual(["ALTER TABLE `iceberg`.`analytics`.`users` ADD COLUMN `nickname` STRING;"]);
|
||||
});
|
||||
|
||||
it("carries the edited script in the draft so the tab reports unsaved work", async () => {
|
||||
const root = await mountStructureEditor();
|
||||
await openDdlTab(root);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, type ComputedRef, type Ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { omitDdlDatabaseQualifier, 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,8 @@ export function useDataGridTableMetadataLoaders(options: DataGridTableMetadataLo
|
||||
try {
|
||||
const { ddl } = await loadObjectDdl(request, { force });
|
||||
const formatDialect = sqlFormatDialectForDbType(options.resolvedDatabaseType.value);
|
||||
state.ddlContent.value = options.settingsStore.editorSettings.generateSqlQuoteIdentifiers ? ddl : omitDdlIdentifierQuotes(ddl, formatDialect);
|
||||
const unqualified = omitDdlDatabaseQualifier(ddl, formatDialect, options.resolvedDatabaseType.value, options.settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.tableMeta?.catalog);
|
||||
state.ddlContent.value = options.settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect);
|
||||
} catch (error: any) {
|
||||
state.ddlContent.value = `-- Error: ${error}`;
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildSidebarDdlTemplateSql, sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate";
|
||||
import { buildSidebarDdlTemplateSql, formatSidebarDdlTemplateForDisplay, sidebarDdlTargetsForExecutionContext } from "@/lib/sidebar/sidebarDdlTemplate";
|
||||
|
||||
describe("sidebar DDL template", () => {
|
||||
it("preserves the single-target SQL", async () => {
|
||||
@@ -26,6 +26,30 @@ describe("sidebar DDL template", () => {
|
||||
expect(sql).toBe("CREATE TABLE one (id INT) /* one */;\n\nCREATE TABLE two (id INT) /* two */;\n");
|
||||
});
|
||||
|
||||
it("applies the database-name setting to a single target", async () => {
|
||||
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),
|
||||
);
|
||||
|
||||
expect(sql).toBe('CREATE TABLE "TEST" ("ID" NUMBER)');
|
||||
});
|
||||
|
||||
it("keeps external-catalog qualifiers for multiple targets", async () => {
|
||||
const targets = [
|
||||
{ name: "events", catalog: "iceberg" },
|
||||
{ name: "sessions", catalog: "iceberg" },
|
||||
];
|
||||
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),
|
||||
);
|
||||
|
||||
expect(sql).toBe("CREATE TABLE iceberg.analytics.events (id bigint);\n\nCREATE TABLE iceberg.analytics.sessions (id bigint);\n");
|
||||
});
|
||||
|
||||
it("keeps DDL targets in the active SQL execution context", () => {
|
||||
const active = { id: "active", connectionId: "c1", database: "db1", catalog: "catalog1" };
|
||||
const sameContext = { id: "same", connectionId: "c1", database: "db1", catalog: "catalog1" };
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { joinExportedDdls } from "@/lib/export/ddlExport";
|
||||
import { omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
interface SidebarDdlExecutionTarget {
|
||||
connectionId: string;
|
||||
@@ -17,3 +20,8 @@ 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);
|
||||
return quoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, dialect);
|
||||
}
|
||||
|
||||
+54
-1
@@ -1,5 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
import { formatGeneratedDdlIdentifierQuotes, omitDdlDatabaseQualifier, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||||
|
||||
describe("omitDdlDatabaseQualifier", () => {
|
||||
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";');
|
||||
});
|
||||
|
||||
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\';');
|
||||
});
|
||||
|
||||
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);');
|
||||
});
|
||||
|
||||
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";');
|
||||
});
|
||||
|
||||
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";');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("omitDdlIdentifierQuotes", () => {
|
||||
it("removes safe MySQL identifier quotes without changing literals or comments", () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { requiresDamengIdentifierQuote, requiresMysqlIdentifierQuote, requiresOracleIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier";
|
||||
import { tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
|
||||
import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
|
||||
import type { SqlSemanticToken } from "@/lib/sql/semantic/types";
|
||||
import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { dropsSchemaQualifier } from "@/lib/table/tableSelectSql";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const SIMPLE_SQLSERVER_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
@@ -74,3 +77,167 @@ export function formatGeneratedDdlIdentifierQuotes(sql: string, dialect: SqlForm
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface DdlSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Object keywords that can follow `CREATE` once its modifiers are consumed. */
|
||||
const DDL_CREATE_OBJECT_WORDS = new Set(["table", "view", "index"]);
|
||||
|
||||
/**
|
||||
* Modifiers that may appear between `CREATE` and the object keyword. Anything
|
||||
* outside this set (`function`, `procedure`, `sequence`, ...) is treated as an
|
||||
* object kind we do not rewrite, which keeps unrelated DDL untouched.
|
||||
*/
|
||||
const DDL_CREATE_MODIFIER_WORDS = new Set(["global", "local", "materialized", "or", "replace", "temporary", "temp", "unlogged", "unique", "clustered", "nonclustered", "columnstore", "fulltext", "spatial", "external"]);
|
||||
|
||||
const DDL_ALTER_OBJECT_WORDS = new Set(["table", "view", "index"]);
|
||||
const DDL_DROP_OBJECT_WORDS = new Set(["table", "view", "index"]);
|
||||
const DDL_COMMENT_OBJECT_WORDS = new Set(["table", "view", "index", "materialized"]);
|
||||
|
||||
/** Skips an optional keyword sequence, returning the original index when it does not match. */
|
||||
function skipDdlWords(tokens: readonly SqlSemanticToken[], index: number, end: number, words: readonly string[]): number {
|
||||
let cursor = index;
|
||||
for (const word of words) {
|
||||
const token = tokens[cursor];
|
||||
if (cursor >= end || !token || token.kind !== "word" || token.normalized !== word) return index;
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** Token indexes of every identifier part of the qualified name starting at `start`. */
|
||||
function readDdlQualifiedName(tokens: readonly SqlSemanticToken[], start: number, end: number): number[] | undefined {
|
||||
if (start >= end || !tokenIsIdentifier(tokens[start])) return undefined;
|
||||
const parts = [start];
|
||||
let cursor = start + 1;
|
||||
while (cursor + 1 < end && tokens[cursor]!.text === "." && tokenIsIdentifier(tokens[cursor + 1])) {
|
||||
parts.push(cursor + 1);
|
||||
cursor += 2;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function findDdlKeyword(tokens: readonly SqlSemanticToken[], start: number, end: number, word: string): number {
|
||||
for (let cursor = start; cursor < end; cursor += 1) {
|
||||
const token = tokens[cursor]!;
|
||||
if (token.depth === 0 && token.kind === "word" && token.normalized === word) return cursor;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the span covering the qualifier chain of a qualified name, keeping
|
||||
* only the trailing `keep` parts (`SYSTEM.TEST` with `keep: 1` removes `SYSTEM.`).
|
||||
*/
|
||||
function collectDdlQualifierRemoval(tokens: readonly SqlSemanticToken[], parts: readonly number[], keep: number, out: DdlSpan[]): void {
|
||||
const firstKept = parts.length - keep;
|
||||
if (firstKept <= 0) return;
|
||||
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 {
|
||||
let index = from;
|
||||
while (index < to && tokens[index]!.kind === "comment") index += 1;
|
||||
const head = tokens[index];
|
||||
if (!head || head.kind !== "word") return;
|
||||
|
||||
if (head.normalized === "create") {
|
||||
let cursor = index + 1;
|
||||
while (cursor < to) {
|
||||
const token = tokens[cursor]!;
|
||||
if (token.kind !== "word") return;
|
||||
if (DDL_CREATE_OBJECT_WORDS.has(token.normalized)) break;
|
||||
if (!DDL_CREATE_MODIFIER_WORDS.has(token.normalized)) return;
|
||||
cursor += 1;
|
||||
}
|
||||
const objectWord = tokens[cursor];
|
||||
if (!objectWord) return;
|
||||
cursor = skipDdlWords(tokens, cursor + 1, to, ["if", "not", "exists"]);
|
||||
const nameParts = readDdlQualifiedName(tokens, cursor, to);
|
||||
if (!nameParts) return;
|
||||
collectDdlQualifierRemoval(tokens, nameParts, 1, out);
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (head.normalized === "alter") {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (head.normalized === "drop") {
|
||||
const objectWord = tokens[index + 1];
|
||||
if (!objectWord || objectWord.kind !== "word" || !DDL_DROP_OBJECT_WORDS.has(objectWord.normalized)) return;
|
||||
let cursor = skipDdlWords(tokens, index + 2, to, ["if", "exists"]);
|
||||
for (;;) {
|
||||
const nameParts = readDdlQualifiedName(tokens, cursor, to);
|
||||
if (!nameParts) return;
|
||||
collectDdlQualifierRemoval(tokens, nameParts, 1, out);
|
||||
cursor = nameParts[nameParts.length - 1]! + 1;
|
||||
if (tokens[cursor]?.text !== ",") return;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (head.normalized === "truncate") {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (head.normalized === "comment") {
|
||||
if (tokens[index + 1]?.normalized !== "on") return;
|
||||
const objectWord = tokens[index + 2];
|
||||
if (!objectWord || objectWord.kind !== "word") return;
|
||||
const isColumn = objectWord.normalized === "column";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export function omitDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect, databaseType: DatabaseType | undefined, includeDatabaseName: boolean, catalog?: string): string {
|
||||
if (!dropsSchemaQualifier(databaseType, includeDatabaseName, catalog)) return sql;
|
||||
const tokens = tokenizeSqlSemantic(sql, dialect === "sqlserver" ? "sqlserver" : dialect);
|
||||
if (tokens.some((token) => token.closed === false)) return sql;
|
||||
|
||||
const removals: DdlSpan[] = [];
|
||||
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;
|
||||
collectDdlStatementQualifierRemovals(tokens, statementStart, index, removals);
|
||||
statementStart = index + 1;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -44,8 +44,9 @@ const DATABASE_QUALIFIED_TABLE_TYPES = new Set<DatabaseType>(["mysql", "clickhou
|
||||
// name" on schema-aware engines — except for databases that can only address
|
||||
// objects through their full qualified name (`catalog.schema.table` /
|
||||
// `database.schema.table`), where dropping it would break the query.
|
||||
export function dropsSchemaQualifier(databaseType: DatabaseType | undefined, includeDatabaseName?: boolean): boolean {
|
||||
return includeDatabaseName === false && databaseType !== undefined && !DATABASE_SCHEMA_QUALIFIED_TYPES.has(databaseType);
|
||||
export function dropsSchemaQualifier(databaseType: DatabaseType | undefined, includeDatabaseName?: boolean, catalog?: string): boolean {
|
||||
if (includeDatabaseName !== false || databaseType === undefined || DATABASE_SCHEMA_QUALIFIED_TYPES.has(databaseType)) return false;
|
||||
return !catalog || catalog === "internal" || (databaseType !== "doris" && databaseType !== "starrocks");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,12 +56,7 @@ export function dropsSchemaQualifier(databaseType: DatabaseType | undefined, inc
|
||||
* SELECT templates do (#9326).
|
||||
*/
|
||||
export function tableMetaWithoutOptionalDatabaseQualifier<T extends { schema?: string; database?: string; catalog?: string }>(tableMeta: T | undefined, databaseType: DatabaseType | undefined, includeDatabaseName?: boolean): T | undefined {
|
||||
if (!tableMeta || !dropsSchemaQualifier(databaseType, includeDatabaseName)) return tableMeta;
|
||||
// Doris/StarRocks external-catalog tables are only addressable through the
|
||||
// 3-part `catalog.database.table` form; stripping the middle segment would
|
||||
// retarget the generated SQL, so keep the qualifiers (same rule as
|
||||
// `qualifiedTableName`).
|
||||
if (tableMeta.catalog && tableMeta.catalog !== "internal" && (databaseType === "doris" || databaseType === "starrocks")) return tableMeta;
|
||||
if (!tableMeta || !dropsSchemaQualifier(databaseType, includeDatabaseName, tableMeta.catalog)) return tableMeta;
|
||||
if (tableMeta.schema === undefined && tableMeta.database === undefined) return tableMeta;
|
||||
return { ...tableMeta, schema: undefined, database: undefined };
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Reference in New Issue
Block a user