fix(grid): honor extractor database-name option when copying SQL

This commit is contained in:
高桥凉介
2026-10-08 13:55:15 +08:00
committed by GitHub
parent 5b2772c444
commit 026e0b539d
10 changed files with 188 additions and 15 deletions
@@ -211,9 +211,7 @@ function save() {
<label v-if="draftExtractor === 'sql-inserts'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.sql.excludePrimaryKeysFromInsert" @change="updateSql('excludePrimaryKeysFromInsert', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorExcludePrimaryKeys") }}</label
>
<label v-if="draftExtractor === 'sql-inserts'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.sql.includeDatabaseName" @change="updateSql('includeDatabaseName', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeDatabaseName") }}</label
>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.includeDatabaseName" @change="updateSql('includeDatabaseName', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeDatabaseName") }}</label>
<div v-if="draftExtractor === 'sql-inserts'" class="space-y-1.5">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.quoteIdentifiers" @change="updateSql('quoteIdentifiers', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorQuoteIdentifiers") }}</label>
<Label>{{ t("grid.copyExtractorTemporalFormat") }}</Label>
@@ -103,6 +103,20 @@ describe("extractor draft preview", () => {
expect(host.querySelector("pre")?.textContent).toBe("initial");
});
it("offers the database-name option for SQL updates as well (#11195)", async () => {
const { props, host, save } = mountDialog();
props.preference = "sql-updates";
props.items = [{ value: "sql-updates", label: "UPDATE" }];
props.open = true;
await advancePreview();
// 勾选后保存应带上 includeDatabaseName(复制为 UPDATE 也支持带库名/模式名)
await toggle(host, "copyExtractorIncludeDatabaseName");
const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.includes("common.save"))!;
button.click();
expect(save).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ sql: expect.objectContaining({ includeDatabaseName: true }) }) }));
});
it("previews unsaved quote and temporal options without mutating saved settings", async () => {
const { props, host, preview, save } = mountDialog();
props.open = true;
@@ -1289,6 +1289,22 @@ describe("useDataGridExport prepared row statements", () => {
expect(vi.mocked(extractDataGridSelection).mock.lastCall?.[0].options.sql).toEqual(saved.sql);
});
it("follows the extractor option for the copied SQL table qualifier (#11195)", async () => {
const tableMeta: DataGridTableMeta = { ...editableTable, schema: "SYSDBA" };
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0, 1], columns: ["id", "name"], rows: [[1, "Ada"]] };
vi.mocked(extractDataGridSelection).mockResolvedValue({ text: "copied", mimeType: "text/plain", fileExtension: "sql", rowCount: 1, columnCount: 2 });
const withDatabaseName = { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, sql: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.sql, includeDatabaseName: true } };
const enabled = createExportState(tableMeta, ["id", "name"], matrix, [1, "Ada"], undefined, matrix.rows, [], withDatabaseName, false, undefined, false, undefined, undefined, "dameng");
await enabled.copyWithExtractor("sql-inserts");
expect(vi.mocked(extractDataGridSelection).mock.lastCall?.[0].tableMeta).toMatchObject({ schema: "SYSDBA", tableName: "users" });
// 未勾选(默认)时保持既有行为:把可选的库名/模式名前缀交给后端省略
const disabled = createExportState(tableMeta, ["id", "name"], matrix, [1, "Ada"], undefined, matrix.rows, [], DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, false, undefined, false, undefined, undefined, "dameng");
await disabled.copyWithExtractor("sql-inserts");
expect(vi.mocked(extractDataGridSelection).mock.lastCall?.[0].tableMeta?.schema).toBeUndefined();
});
it("does not report success when an extractor export save is cancelled", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [1], columns: ["name"], rows: [["Ada"]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: '[{"name":"Ada"}]', mimeType: "application/json", fileExtension: "json", rowCount: 1, columnCount: 1 });
@@ -0,0 +1,89 @@
import { computed, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridExtractor } from "@/composables/useDataGridExtractor";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import type { DatabaseType } from "@/types/database";
const mocks = vi.hoisted(() => ({ extract: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({ extractDataGridSelection: mocks.extract }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
function createState(databaseType: DatabaseType, tableMeta: DataGridTableMeta, extractorOptions?: DataGridExtractorOptions, includeDatabaseName = false) {
const items = [{ id: 1, data: [1, "Ada"], isDraft: false }];
return useDataGridExtractor({
columns: computed(() => ["id", "name"]),
allColumns: computed(() => ["id", "name"]),
displayItems: computed(() => items),
allDisplayItems: computed(() => items),
allSourceColumns: computed(() => undefined),
visibleColumnIndexes: computed(() => [0, 1]),
columnTypes: computed(() => undefined),
extractorOptions: computed(() => extractorOptions ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS),
databaseType: computed(() => databaseType),
identifierQuote: computed(() => undefined),
tableMeta: computed(() => tableMeta),
includeDatabaseName: computed(() => includeDatabaseName),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedCellMatrix: computed(() => null),
hasRowSelection: computed(() => true),
hasColumnSelection: computed(() => false),
selectedRowIds: ref(new Set<number>([1])),
contextCell: ref(null),
contextSelectionIsSynthetic: ref(false),
copyText: vi.fn(async () => true),
canCopySqlInsert: () => true,
buildMongoInsert: vi.fn(async () => undefined),
});
}
function qualifiedMeta(schema?: string, database?: string): DataGridTableMeta {
return {
tableName: "users",
schema,
database,
primaryKeys: ["id"],
columns: [
{ name: "id", data_type: "int" },
{ name: "name", data_type: "varchar" },
],
};
}
async function capturedRequest(databaseType: DatabaseType, tableMeta: DataGridTableMeta, extractorOptions?: DataGridExtractorOptions, includeDatabaseName = false) {
const state = createState(databaseType, tableMeta, extractorOptions, includeDatabaseName);
const extraction = await state.extractWithExtractor("sql-inserts");
expect(extraction).not.toBeNull();
return mocks.extract.mock.calls[mocks.extract.mock.calls.length - 1]![0]!;
}
describe("useDataGridExtractor includeDatabaseName payload", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.extract.mockResolvedValue({ text: "INSERT INTO users VALUES (1, 'Ada');", rowCount: 1 });
});
it.each([
["sqlserver", "dbo", "app"],
["snowflake", "PUBLIC", undefined],
] as const)("keeps the namespace flag true for %s even when the option is off", async (databaseType, schema, database) => {
const request = await capturedRequest(databaseType, qualifiedMeta(schema, database));
expect(request.tableMeta.schema).toBe(schema);
expect(request.options.sql.includeDatabaseName).toBe(true);
});
it("strips optional qualifiers and keeps the flag off for non schema-qualified dialects", async () => {
const request = await capturedRequest("mysql", qualifiedMeta(undefined, "mydb"));
expect(request.tableMeta.database).toBeUndefined();
expect(request.tableMeta.schema).toBeUndefined();
expect(request.options.sql.includeDatabaseName).toBe(false);
});
it("preserves an explicit opt-in for non schema-qualified dialects", async () => {
const request = await capturedRequest("mysql", qualifiedMeta(undefined, "mydb"), { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, sql: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.sql, includeDatabaseName: true } });
expect(request.tableMeta.database).toBe("mydb");
expect(request.options.sql.includeDatabaseName).toBe(true);
});
});
@@ -230,6 +230,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const presentBinaryText = descriptor.category !== "sql";
const rawRows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => row[sourceIndex]));
const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, keepUnsafeJsonText, presentBinaryText, index)));
// 「包含数据库名称」:复制/导出 SQL 时,提取器自身的勾选框与编辑器全局设置
// (`generateSqlIncludeDatabaseName`)任一开启都保留库名/模式名;后者还会影响
// 保存 SQL、侧栏导出等其它路径,因此这里取“或”,不覆盖全局偏好。
const extractorOptionValues = normalizeDataGridExtractorOptions(extractorOptions);
const tableMeta =
descriptor.category === "sql"
? tableMetaWithoutOptionalDatabaseQualifier(
@@ -238,7 +242,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
columns.map((column) => column.sourceName ?? column.displayName),
),
options.databaseType.value,
options.includeDatabaseName?.value,
options.includeDatabaseName?.value === true || extractorOptionValues.sql.includeDatabaseName === true,
)
: undefined;
const request: DataGridExtractRequest = {
@@ -251,7 +255,12 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
selectedColumnIndexes,
rows,
selectionKind,
options: normalizeDataGridExtractorOptions(extractorOptions),
// For dialects whose tableMeta keeps the schema qualifier regardless of
// the flag (DATABASE_SCHEMA_QUALIFIED_TYPES), the backend strips every
// namespace prefix when include_database_name=false, which would regress
// the default output to a bare table name; keep the flag aligned with
// the qualified tableMeta we intentionally preserved.
options: tableMeta && (tableMeta.schema !== undefined || tableMeta.database !== undefined) ? { ...extractorOptionValues, sql: { ...extractorOptionValues.sql, includeDatabaseName: true } } : extractorOptionValues,
};
requestSources.set(request, {
rowIds: sourceRowIds,
@@ -12,11 +12,13 @@ describe("data-grid extractor options", () => {
expect(normalizeDataGridExtractorOptions({ sql: { quoteIdentifiers: "false", temporalFormat: "custom" } }).sql).toMatchObject({ quoteIdentifiers: true, temporalFormat: "native" });
});
it("keeps database qualification for legacy options and persists explicit opt-out", () => {
expect(normalizeDataGridExtractorOptions({ sql: {} }).sql.includeDatabaseName).toBe(true);
const configured = normalizeDataGridExtractorOptions({ sql: { includeDatabaseName: false } });
expect(normalizeDataGridExtractorOptions(JSON.parse(JSON.stringify(configured))).sql.includeDatabaseName).toBe(false);
expect(normalizeDataGridExtractorOptions({ sql: { includeDatabaseName: "false" } }).sql.includeDatabaseName).toBe(true);
it("defaults to dropping database qualification and keeps an explicit opt-in", () => {
expect(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.sql.includeDatabaseName).toBe(false);
expect(normalizeDataGridExtractorOptions({ sql: {} }).sql.includeDatabaseName).toBe(false);
const configured = normalizeDataGridExtractorOptions({ sql: { includeDatabaseName: true } });
expect(configured.sql.includeDatabaseName).toBe(true);
expect(normalizeDataGridExtractorOptions(JSON.parse(JSON.stringify(configured))).sql.includeDatabaseName).toBe(true);
expect(normalizeDataGridExtractorOptions({ sql: { includeDatabaseName: "true" } }).sql.includeDatabaseName).toBe(false);
});
it("defaults DSV NULL output to an empty spreadsheet field", () => {
@@ -239,6 +239,20 @@ describe("tableMetaWithoutOptionalDatabaseQualifier — copy extractors (#9326)"
expect(tableMetaWithoutOptionalDatabaseQualifier({ schema: "public", tableName: "users" }, "postgres", false)).toEqual({ schema: undefined, tableName: "users" });
});
it("covers the copy-SQL dialects reported in #11195", () => {
// Dameng exposes schemas as databases; a copied INSERT/UPDATE must still be
// able to opt into the schema prefix, so the qualifier only drops when the
// extractor option is off.
const damengMeta = { schema: "SYSDBA", tableName: "PEOPLE" };
expect(tableMetaWithoutOptionalDatabaseQualifier(damengMeta, "dameng", false)).toEqual({ schema: undefined, tableName: "PEOPLE" });
expect(tableMetaWithoutOptionalDatabaseQualifier(damengMeta, "dameng", true)).toBe(damengMeta);
// MySQL family and ClickHouse address tables as database.table.
const clickhouseMeta = { database: "analytics", tableName: "events" };
expect(tableMetaWithoutOptionalDatabaseQualifier(clickhouseMeta, "clickhouse", false)).toEqual({ database: undefined, tableName: "events" });
expect(tableMetaWithoutOptionalDatabaseQualifier(clickhouseMeta, "clickhouse", true)).toBe(clickhouseMeta);
expect(tableMetaWithoutOptionalDatabaseQualifier({ database: "appdb", tableName: "people" }, "goldendb", false)).toEqual({ database: undefined, tableName: "people" });
});
it("returns undefined tableMeta unchanged", () => {
expect(tableMetaWithoutOptionalDatabaseQualifier(undefined, "mysql", false)).toBeUndefined();
});
@@ -29,7 +29,9 @@ export const DATA_GRID_DEFAULT_COPY_PREFERENCES: readonly DataGridCopyPreference
];
export const DATA_GRID_EXTRACTOR_CONTRACT_VERSION = 1 as const;
export const DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION = 1 as const;
// v2:复制/导出 SQL 的「包含数据库名称」改为由提取器自身的勾选框决定,默认关闭(不带库名/模式名),
// 历史持久化的 `true` 由 settingsStore 的迁移归位到新默认。
export const DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION = 2 as const;
export const DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS: Record<DataGridCopyExtractorId, { category: DataGridExtractorCategory; separatorBefore?: boolean }> = {
raw: { category: "raw" },
@@ -151,7 +153,7 @@ export const DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS: DataGridExtractorOptions = {
skipGeneratedColumns: true,
insertMode: "merged",
excludePrimaryKeysFromInsert: false,
includeDatabaseName: true,
includeDatabaseName: false,
quoteIdentifiers: true,
temporalFormat: "native",
},
@@ -184,7 +186,7 @@ export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtra
skipGeneratedColumns: sql.skipGeneratedColumns !== false,
insertMode: sql.insertMode === "row-by-row" ? "row-by-row" : "merged",
excludePrimaryKeysFromInsert: sql.excludePrimaryKeysFromInsert === true,
includeDatabaseName: sql.includeDatabaseName !== false,
includeDatabaseName: sql.includeDatabaseName === true,
quoteIdentifiers: sql.quoteIdentifiers !== false,
temporalFormat: sql.temporalFormat === "string" ? "string" : "native",
},
+17
View File
@@ -611,6 +611,23 @@ describe("normalizeEditorSettings", () => {
expect(configured.dataGridExtractorOptions.dsv.nullText).toBe("NULL");
});
it("resets the copy extractor database-name opt-in once when migrating to v2", () => {
const legacy = normalizeEditorSettings({
dataGridExtractorOptionsMigrationVersion: 1,
dataGridExtractorOptions: { sql: { includeDatabaseName: true } },
});
// 历史持久化的「包含数据库名称」归位到新默认:复制/导出 SQL 默认不带库名/模式名
expect(legacy.dataGridExtractorOptions.sql.includeDatabaseName).toBe(false);
expect(legacy.dataGridExtractorOptionsMigrationVersion).toBe(DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION);
const current = normalizeEditorSettings({
dataGridExtractorOptionsMigrationVersion: DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION,
dataGridExtractorOptions: { sql: { includeDatabaseName: true } },
});
// 迁移之后用户的显式勾选必须被保留
expect(current.dataGridExtractorOptions.sql.includeDatabaseName).toBe(true);
});
it("defaults retained result runs to tiled tabs and preserves list mode", () => {
expect(normalizeEditorSettings({}).resultRunDisplayMode).toBe("tabs");
expect(normalizeEditorSettings({ resultRunDisplayMode: "list" }).resultRunDisplayMode).toBe("list");
+14 -2
View File
@@ -1783,7 +1783,14 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
const savedExtractorMigrationVersion = settings.dataGridExtractorOptionsMigrationVersion;
const normalizedExtractorOptions = normalizeDataGridExtractorOptions(settings.dataGridExtractorOptions);
const isLegacyExtractorOptions = typeof savedExtractorMigrationVersion !== "number" || savedExtractorMigrationVersion < DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION;
const dataGridExtractorOptions = isLegacyExtractorOptions && normalizedExtractorOptions.dsv.nullText === "NULL" ? { ...normalizedExtractorOptions, dsv: { ...normalizedExtractorOptions.dsv, nullText: "" } } : normalizedExtractorOptions;
const dataGridExtractorOptions = (() => {
if (!isLegacyExtractorOptions) return normalizedExtractorOptions;
// v1:旧的 "NULL" 文本迁移为空串
const nullTextMigrated = normalizedExtractorOptions.dsv.nullText === "NULL" ? { ...normalizedExtractorOptions, dsv: { ...normalizedExtractorOptions.dsv, nullText: "" } } : normalizedExtractorOptions;
// v2:复制/导出 SQL 的「包含数据库名称」改为由提取器勾选框决定且默认关闭,
// 历史持久化的 true 一并归位到新默认(想带库名/模式名的用户重新勾选即可)
return nullTextMigrated.sql.includeDatabaseName ? { ...nullTextMigrated, sql: { ...nullTextMigrated.sql, includeDatabaseName: false } } : nullTextMigrated;
})();
// Preserve the explicit intent behind the legacy update controls. Disabling
// update reminders was a full opt-out; disabling automatic downloads only
// opted out of downloading the DBX package itself.
@@ -2302,7 +2309,12 @@ export const useSettingsStore = defineStore("settings", () => {
const needsWelcomePageDefaultMigration = typeof savedSettings.welcomePageModeDefaultVersion !== "number" || savedSettings.welcomePageModeDefaultVersion < WELCOME_PAGE_DEFAULT_VERSION;
const needsTabNavigationShortcutMigration = needsTabNavigationHistoryShortcutMigration(savedSettings.shortcuts);
const savedNullText = (savedSettings.dataGridExtractorOptions as Partial<DataGridExtractorOptions> | undefined)?.dsv?.nullText;
const needsDataGridExtractorOptionsMigration = (typeof savedSettings.dataGridExtractorOptionsMigrationVersion !== "number" || savedSettings.dataGridExtractorOptionsMigrationVersion < DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION) && savedNullText === "NULL";
// v2 also resets a historically persisted includeDatabaseName=true, so
// trigger the eager persist for that stale disk value even when the
// v1 "NULL" marker is already migrated away.
const savedSqlIncludeDatabaseName = (savedSettings.dataGridExtractorOptions as Partial<DataGridExtractorOptions> | undefined)?.sql?.includeDatabaseName;
const needsDataGridExtractorOptionsMigration =
(typeof savedSettings.dataGridExtractorOptionsMigrationVersion !== "number" || savedSettings.dataGridExtractorOptionsMigrationVersion < DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION) && (savedNullText === "NULL" || savedSqlIncludeDatabaseName === true);
const savedUpdateDownloadSource = (saved as { updateDownloadSource?: unknown }).updateDownloadSource;
if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration || needsWelcomePageDefaultMigration || needsTabNavigationShortcutMigration || needsSidebarBrowseObjectsMigration || needsDataGridExtractorOptionsMigration) {
// Persist one-time migrations so removed or unsafe defaults cannot reappear.