fix(generate): dedupe unique-constraint columns by default

This commit is contained in:
zipg
2026-09-27 10:29:47 +08:00
committed by GitHub
parent 49b6347c4a
commit 94d9c2d026
5 changed files with 188 additions and 0 deletions
@@ -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<Set<string>> {
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<string>();
}
}
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,
),
@@ -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<string, unknown> = {}): 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);
});
});
@@ -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;
@@ -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);
});
});
@@ -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<string> {
const columns = new Set<string>();
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;
}