diff --git a/apps/desktop/src/components/generate/DataGenerateDialog.vue b/apps/desktop/src/components/generate/DataGenerateDialog.vue index 5284f9c287..415ed98ece 100644 --- a/apps/desktop/src/components/generate/DataGenerateDialog.vue +++ b/apps/desktop/src/components/generate/DataGenerateDialog.vue @@ -20,6 +20,7 @@ import { } from "@/lib/dataGrid/dataGenerate"; import { errorMessage, isQueryCanceledError, summarizeBatchResults } from "@/lib/dataGrid/generateInsertAccounting"; import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql"; +import { uniqueConstraintColumns } from "@/lib/table/uniqueConstraintColumns"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; @@ -162,6 +163,7 @@ async function loadSchemas() { const prefillTableInfo = tables.find((table) => table.name === props.prefillTable); if (prefillTableInfo) { const cols = await api.getColumns(cid, db, targetSchema, props.prefillTable); + const uniqueColumns = await singleColumnUniqueConstraints(cid, db, targetSchema, props.prefillTable); const key = tableKey(targetSchema, props.prefillTable); configs[key] = { tableName: props.prefillTable, @@ -186,6 +188,7 @@ async function loadSchemas() { numericPrecision: c.numeric_precision, numericScale: c.numeric_scale, characterMaximumLength: c.character_maximum_length, + uniqueConstraint: uniqueColumns.has(c.name), }, gKey, ), @@ -216,6 +219,21 @@ async function loadSchemas() { } } +/** + * Columns covered by a single-column unique constraint (PRIMARY KEY or UNIQUE + * index). The generator switches per-column uniqueness on for them so an + * INSERT batch never repeats a value the server will reject (#5958). + */ +async function singleColumnUniqueConstraints(cid: string, db: string, schema: string, table: string): Promise> { + try { + return uniqueConstraintColumns(await api.listIndexes(cid, db, schema, table)); + } catch { + // Index metadata is a best-effort hint: without it the generator keeps the + // previous behaviour instead of failing to open the dialog. + return new Set(); + } +} + async function toggleSchema(schema: string) { if (expandedSchemas[schema]) { expandedSchemas[schema] = false; @@ -261,6 +279,7 @@ async function loadColumns(schema: string, table: string) { if (configs[key]) return configs[key]; if (!props.prefillConnectionId || !props.prefillDatabase) return null; const cols = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, table); + const uniqueColumns = await singleColumnUniqueConstraints(props.prefillConnectionId, props.prefillDatabase, schema, table); const cfg: TableGenerateConfig = { tableName: table, tableType: tableInfo(schema, table)?.table_type, @@ -284,6 +303,7 @@ async function loadColumns(schema: string, table: string) { numericPrecision: c.numeric_precision, numericScale: c.numeric_scale, characterMaximumLength: c.character_maximum_length, + uniqueConstraint: uniqueColumns.has(c.name), }, gKey, ), diff --git a/apps/desktop/src/lib/dataGrid/__tests__/dataGenerateUniqueConstraint.spec.ts b/apps/desktop/src/lib/dataGrid/__tests__/dataGenerateUniqueConstraint.spec.ts new file mode 100644 index 0000000000..562ab578f0 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/__tests__/dataGenerateUniqueConstraint.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { createTableGenerateState, defaultGeneratorParams, generateTableRowsChunk, type ColumnAttrs, type TableGenerateConfig } from "@/lib/dataGrid/dataGenerate"; + +const ROW_COUNT = 400; + +function intColumn(uniqueConstraint: boolean, params: Record = {}): TableGenerateConfig { + const generatorKey = "number"; + const attrs: ColumnAttrs = { + dataType: "int", + isAutoIncrement: false, + columnDefault: null, + numericPrecision: 10, + numericScale: 0, + characterMaximumLength: null, + uniqueConstraint, + }; + return { + tableName: "t_5958", + tableType: "TABLE", + schema: "dbx9428", + database: "dbx9428", + rowCount: ROW_COUNT, + columns: [ + { + columnName: "code", + dataType: "int", + rowCount: ROW_COUNT, + generatorKey, + generatorParams: { ...defaultGeneratorParams("code", attrs, generatorKey), min: 1, max: 10000, ...params }, + isAutoIncrement: false, + columnDefault: null, + }, + ], + }; +} + +describe("data generation with a single-column unique constraint (#5958)", () => { + it("turns per-column uniqueness on for constrained columns only", () => { + const attrs: ColumnAttrs = { dataType: "int", columnDefault: null, uniqueConstraint: true }; + expect(defaultGeneratorParams("code", attrs, "number").unique).toBe(true); + expect(defaultGeneratorParams("code", { ...attrs, uniqueConstraint: false }, "number").unique).toBeUndefined(); + expect(defaultGeneratorParams("code", { ...attrs, uniqueConstraint: undefined }, "number").unique).toBeUndefined(); + }); + + it("leaves generators that are already unique untouched", () => { + const attrs: ColumnAttrs = { dataType: "bigint", columnDefault: null, uniqueConstraint: true }; + expect(defaultGeneratorParams("id", attrs, "sequence").unique).toBeUndefined(); + expect(defaultGeneratorParams("id", { ...attrs, dataType: "uuid" }, "uuid").unique).toBeUndefined(); + }); + + it("generates distinct values for the constrained column", () => { + const config = intColumn(true); + const state = createTableGenerateState(config, "mysql"); + const rows = generateTableRowsChunk(config, state, ROW_COUNT); + const values = rows.map((row) => row[0]); + expect(values).toHaveLength(ROW_COUNT); + expect(new Set(values).size).toBe(ROW_COUNT); + }); + + it("keeps producing duplicates without the constraint hint, which is what made the INSERT fail", () => { + const config = intColumn(false); + const state = createTableGenerateState(config, "mysql"); + const rows = generateTableRowsChunk(config, state, ROW_COUNT); + const values = rows.map((row) => row[0]); + expect(new Set(values).size).toBeLessThan(ROW_COUNT); + }); + + it("still reports exhaustion when the constrained generator only emits NULLs", () => { + const config = intColumn(true, { includeNull: true, nullPercent: 100 }); + const state = createTableGenerateState(config, "mysql"); + expect(() => generateTableRowsChunk(config, state, 5)).toThrow(/unique value.*code.*t_5958/i); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGenerate.ts b/apps/desktop/src/lib/dataGrid/dataGenerate.ts index 66777e3069..bc3d56b1b7 100644 --- a/apps/desktop/src/lib/dataGrid/dataGenerate.ts +++ b/apps/desktop/src/lib/dataGrid/dataGenerate.ts @@ -1195,9 +1195,35 @@ export interface ColumnAttrs { numericPrecision?: number | null; numericScale?: number | null; characterMaximumLength?: number | null; + /// True when a single-column unique constraint (primary key or unique index) + /// covers this column on the target table. + uniqueConstraint?: boolean; } +/// Generators that already produce a distinct value per row, so an extra +/// uniqueness check would only burn attempts without changing the result. +const INHERENTLY_UNIQUE_GENERATORS = new Set(["sequence", "uuid"]); + +/** + * Resolve the default parameters for one column. + * + * Columns covered by a single-column unique constraint (PRIMARY KEY or UNIQUE + * index) must not receive duplicated values: a multi-row INSERT that repeats a + * value is rejected by the server, so the whole batch is lost behind an opaque + * "Statement N failed" error (#5958). Turn the per-column uniqueness + * bookkeeping on by default for those columns; users can still clear the + * "唯一" checkbox when they want raw random values. + * + * `sequence` / `uuid` already emit a distinct value per row, so the extra + * bookkeeping stays off for them. + */ export function defaultGeneratorParams(_columnName: string, attrs: ColumnAttrs, generatorKey: string): GeneratorParams { + const params = buildDefaultGeneratorParams(_columnName, attrs, generatorKey); + if (attrs.uniqueConstraint && !INHERENTLY_UNIQUE_GENERATORS.has(generatorKey)) params.unique = true; + return params; +} + +function buildDefaultGeneratorParams(_columnName: string, attrs: ColumnAttrs, generatorKey: string): GeneratorParams { const params: GeneratorParams = {}; const type = attrs.dataType.toLowerCase(); const precision = attrs.numericPrecision ?? null; diff --git a/apps/desktop/src/lib/table/__tests__/uniqueConstraintColumns.spec.ts b/apps/desktop/src/lib/table/__tests__/uniqueConstraintColumns.spec.ts new file mode 100644 index 0000000000..08f1b31f67 --- /dev/null +++ b/apps/desktop/src/lib/table/__tests__/uniqueConstraintColumns.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { uniqueConstraintColumns } from "@/lib/table/uniqueConstraintColumns"; + +describe("uniqueConstraintColumns", () => { + it("keeps columns of full single-column unique indexes and primary keys", () => { + expect( + uniqueConstraintColumns([ + { columns: ["id"], is_unique: true, filter: null }, + { columns: ["code"], is_unique: true, filter: null }, + ]), + ).toEqual(new Set(["id", "code"])); + }); + + it("ignores non-unique indexes", () => { + expect(uniqueConstraintColumns([{ columns: ["code"], is_unique: false, filter: null }]).size).toBe(0); + }); + + it("ignores composite unique indexes", () => { + expect(uniqueConstraintColumns([{ columns: ["tenant_id", "code"], is_unique: true, filter: null }]).size).toBe(0); + }); + + it("ignores partial unique indexes", () => { + expect(uniqueConstraintColumns([{ columns: ["code"], is_unique: true, filter: "(deleted_at IS NULL)" }]).size).toBe(0); + }); + + it("ignores expression keys", () => { + expect(uniqueConstraintColumns([{ columns: ["lower(code)"], is_unique: true, filter: null, key_is_expression: [true] }]).size).toBe(0); + }); + + it("tolerates missing index metadata", () => { + expect(uniqueConstraintColumns(undefined).size).toBe(0); + expect(uniqueConstraintColumns(null).size).toBe(0); + expect(uniqueConstraintColumns([]).size).toBe(0); + }); +}); diff --git a/apps/desktop/src/lib/table/uniqueConstraintColumns.ts b/apps/desktop/src/lib/table/uniqueConstraintColumns.ts new file mode 100644 index 0000000000..65bbbd88ef --- /dev/null +++ b/apps/desktop/src/lib/table/uniqueConstraintColumns.ts @@ -0,0 +1,34 @@ +/** + * Index metadata shape needed to decide whether a column is uniquely + * constrained on its own. Mirrors the fields DBX returns from `listIndexes`. + */ +export interface UniqueConstraintIndex { + columns: string[]; + is_unique: boolean; + filter?: string | null; + key_is_expression?: boolean[] | null; +} + +/** + * Columns that a full, single-column unique index (or single-column primary + * key) constrains, so the data generator knows which columns cannot repeat a + * value (#5958). + * + * Only indexes that guarantee per-column uniqueness qualify: + * - Composite indexes are skipped: the table still accepts duplicate values in + * each participating column, so deduplicating a single column would be + * stricter than the real constraint and could exhaust a small value space. + * - Partial indexes (`filter` set) are skipped for the same reason. + * - Expression keys are skipped because they have no column name to match. + */ +export function uniqueConstraintColumns(indexes: readonly UniqueConstraintIndex[] | null | undefined): Set { + const columns = new Set(); + for (const index of indexes ?? []) { + if (!index.is_unique || index.filter) continue; + if (index.columns.length !== 1) continue; + const [column] = index.columns; + if (!column || index.key_is_expression?.[0]) continue; + columns.add(column); + } + return columns; +}