mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(generate): dedupe unique-constraint columns by default
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user