fix(doris): keep UNIQUE KEY columns unique-only and scope the nullable row id

This commit is contained in:
t8y2 committed 2026-10-25 01:19:55 +08:00
1 parent b0e6bc82be
commit a92fc75864
4 files changed
+92 -43

No files matched your search

@@ -32,7 +32,7 @@ import {
} from "@/lib/table/tableImport";
import { importPreviewInput, importSourceDisplayName, uploadedImportSourceFromPreview } from "@/lib/import/importSource";
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
import { editableRowIdentifierColumns } from "@/lib/table/tableEditing";
import { uniqueKeyModelColumns } from "@/lib/table/tableEditing";
import { metadataSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import { schemaOptionsForConnection } from "@/composables/useSchemaOptions";
@@ -768,20 +768,14 @@ async function loadTargetColumns() {
TARGET_COLUMNS_TIMEOUT_MS,
)
: Promise.resolve([]);
const [columns, indexes] = await Promise.all([columnsPromise, indexesPromise]);
const [loadedColumns, indexes] = await Promise.all([columnsPromise, indexesPromise]);
if (requestId !== targetColumnsRequestId) return;
if (isUniqueKeyModelDatabase.value && indexes.length > 0) {
const keyCols = editableRowIdentifierColumns(structureDatabaseType.value, columns, indexes);
if (keyCols.length > 0) {
const keySet = new Set(keyCols.map((c) => c.toLowerCase()));
for (const col of columns) {
if (keySet.has(col.name.toLowerCase())) {
col.is_unique = true;
col.is_primary_key = true;
}
}
}
}
// Doris/StarRocks UNIQUE KEY columns are the conflict key but are NULLable and
// not reported as primary keys. Flag only `is_unique`: this array is not
// copied, so writing `is_primary_key` here would also make unrelated metadata
// readers treat the column as a literal PRIMARY KEY.
const keyColumns = uniqueKeyModelColumns(structureDatabaseType.value, indexes);
const columns = keyColumns.size > 0 ? loadedColumns.map((column) => (keyColumns.has(column.name.toLowerCase()) ? { ...column, is_unique: true } : column)) : loadedColumns;
targetColumns.value = columns;
loadedTargetTableName.value = tableName;
applyAutoMapping();
+22 -1
View File
@@ -19,6 +19,7 @@ import {
isTableDataEditable,
parseSalesforceColumnExtra,
physicalTablePrimaryKeys,
uniqueKeyModelColumns,
supportsDataGridTransaction,
shouldIncludeSyntheticRowId,
showsKeylessEditWarning,
@@ -165,7 +166,7 @@ describe("tableEditing", () => {
it("recognizes Doris/StarRocks composite unique keys with nullable columns as row identifier", () => {
// In Doris/StarRocks, UNIQUE KEY columns default to nullable in DDL
const columns = [column("id"), column("data_type")];
const columns = [column("id"), column("data_type"), column("name")];
const dorisUniqueIndex: IndexInfo = {
name: "UNIQUE KEY",
columns: ["id", "data_type"],
@@ -187,6 +188,26 @@ describe("tableEditing", () => {
expect(physicalTablePrimaryKeys(columns, [dorisUniqueIndex], "doris")).toEqual(["id", "data_type"]);
expect(physicalTablePrimaryKeys(columns, [dorisUniqueIndex], "starrocks")).toEqual(["id", "data_type"]);
expect(physicalTablePrimaryKeys(columns, [dorisUniqueIndex], "mysql")).toEqual([]);
// A Doris key spanning every projected column leaves nothing to update.
expect(editableRowIdentifierColumns("doris", columns.slice(0, 2), [dorisUniqueIndex])).toEqual([]);
});
it("prefers the Doris UNIQUE KEY model over a nullable unique *secondary* index", () => {
// UNIQUE KEY(k1, k2) with `... UNIQUE INDEX uk_tail(tail) USING INVERTED`
const columns = [column("k1"), column("k2"), column("tail")];
const modelIndex: IndexInfo = { name: "UNIQUE KEY", columns: ["k1", "k2"], is_unique: true, is_primary: false, index_type: "UNIQUE KEY" };
const secondaryIndex: IndexInfo = { name: "uk_tail", columns: ["tail"], is_unique: true, is_primary: false, index_type: "INVERTED" };
expect(editableRowIdentifierColumns("doris", columns, [modelIndex, secondaryIndex])).toEqual(["k1", "k2"]);
expect(editableRowIdentifierColumns("doris", columns, [secondaryIndex, modelIndex])).toEqual(["k1", "k2"]);
});
it("keys every column like a NULLable UNIQUE KEY model but only makes non-key columns unique", () => {
const columns = [column("id"), column("data_type"), column("name")];
const modelIndex: IndexInfo = { name: "UNIQUE KEY", columns: ["id", "data_type"], is_unique: true, is_primary: false, index_type: "UNIQUE KEY" };
expect([...uniqueKeyModelColumns("doris", [modelIndex])]).toEqual(["id", "data_type"]);
expect([...uniqueKeyModelColumns("mysql", [modelIndex])]).toEqual([]);
expect(uniqueKeyModelColumns("doris", []).size).toBe(0);
});
it.each(["oracle", "oceanbase-oracle"] as const)("prefers physical %s indexes over the ROWID fallback", (databaseType) => {
@@ -1,6 +1,6 @@
import type { ColumnInfo, DatabaseType, IndexInfo, QueryTab } from "@/types/database";
import * as api from "@/lib/backend/api";
import { editableRowIdentifierColumns, physicalTablePrimaryKeys } from "@/lib/table/tableEditing";
import { editableRowIdentifierColumns, physicalTablePrimaryKeys, uniqueKeyModelColumns } from "@/lib/table/tableEditing";
import { loadVirtualRowIdentifier } from "@/lib/table/virtualRowIdentifier";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadCacheStatus, type MetadataLoadTraceLogger } from "./metadataLoadCoordinator";
import { metadataScopeKey, metadataScopeParts, type MetadataScopeInput } from "./metadataLoadScope";
@@ -362,21 +362,23 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
};
const indexesPromise = resolveReportedSchema || resolveSynonymTarget ? undefined : loadTableIndexes(request).catch(() => undefined);
const columnsResult = await columnsPromise;
const columns = columnsResult.columns;
let columns = columnsResult.columns;
const resolvedTarget = columns.find((column) => column.resolved_table);
const resolvedSchema = resolveReportedSchema ? columns.find((column) => column.resolved_schema)?.resolved_schema : request.schema;
const indexRequest = { ...request, schema: resolvedTarget?.resolved_schema ?? (resolvedSchema || request.currentSchema), tableName: resolvedTarget?.resolved_table ?? request.tableName };
const loadedIndexes = columns.length > 0 ? await ((!resolvedTarget && indexesPromise) || loadTableIndexes(indexRequest).catch(() => undefined)) : [];
const indexes = loadedIndexes ?? indexFailure();
const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, columnsResult.tableType);
if ((request.databaseType === "doris" || request.databaseType === "starrocks") && primaryKeys.length > 0) {
const keySet = new Set(primaryKeys.map((name) => name.toLowerCase()));
for (const column of columns) {
if (keySet.has(column.name.toLowerCase())) {
column.is_primary_key = true;
column.is_unique = true;
}
}
let primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, columnsResult.tableType);
// Doris/StarRocks UNIQUE KEY columns are NULLable and never reported as
// primary keys, yet they are the engine's key. Surface them through
// `is_unique` only: the data generator, CREATE/DDL export, transfer and the
// ER diagram read `is_primary_key` as a literal PRIMARY KEY, and `columns`
// may reference the cached columns facet, so rewriting the flag would leak
// into those paths and into every later metadata load.
const keyColumns = uniqueKeyModelColumns(request.databaseType as DatabaseType, indexes);
if (keyColumns.size > 0) {
if (primaryKeys.length === 0) primaryKeys = columns.filter((column) => keyColumns.has(column.name.toLowerCase())).map((column) => column.name);
columns = columns.map((column) => (keyColumns.has(column.name.toLowerCase()) && !column.is_unique ? { ...column, is_unique: true } : column));
}
return {
schema: resolvedSchema || undefined,
+49 -17
View File
@@ -37,17 +37,56 @@ export function editablePrimaryKeys(databaseType: DatabaseType | undefined, colu
return primaryKeys;
}
/** Physical primary keys only; unlike editable row identifiers, never fall back to unique indexes or synthetic keys. */
export function physicalTablePrimaryKeys(columns: readonly Pick<ColumnInfo, "name" | "is_primary_key">[], indexes: readonly Pick<IndexInfo, "columns" | "is_primary" | "is_unique" | "index_type" | "name">[] = [], databaseType?: DatabaseType): string[] {
const UNIQUE_KEY_MODEL_NAME = "UNIQUE KEY";
function isUniqueKeyModelDatabase(databaseType: DatabaseType | undefined): boolean {
return databaseType === "doris" || databaseType === "starrocks";
}
/** Doris/StarRocks UNIQUE KEY model declaration columns, which may be NULLable. */
function isUniqueKeyModelIndex(index: Pick<IndexInfo, "columns" | "index_type" | "name">): boolean {
return index.columns.length > 0 && (index.index_type || index.name || "").toUpperCase() === UNIQUE_KEY_MODEL_NAME;
}
/**
* Whether an index can address exactly one storage row. MySQL-family engines
* reject NULL in a unique index, so a nullable column makes the set ambiguous.
* Doris/StarRocks UNIQUE KEY columns are NULLable by design and are still the
* engine's key, and a table distributed by those columns stores one row per
* unique key, so NULLs cannot split a row into several.
*/
function indexIdentifiesSingleRow(databaseType: DatabaseType | undefined, index: IndexInfo, columnByName: Map<string, ColumnInfo>): boolean {
if (index.filter?.trim() || index.columns.length === 0 || (!index.is_primary && !index.is_unique) || index.key_is_expression?.some(Boolean)) return false;
if (!index.columns.every((name) => columnByName.has(name.toLowerCase()))) return false;
// A key spanning every projected column leaves nothing to update.
if (isUniqueKeyModelDatabase(databaseType) && isUniqueKeyModelIndex(index)) return index.columns.length < columnByName.size;
return index.columns.every((name) => columnByName.get(name.toLowerCase())?.is_nullable === false);
}
/**
* Doris/StarRocks UNIQUE KEY model columns are the engine's key columns even
* though they are nullable and are not reported as primary keys. Expose them
* through `is_unique` so the object browser shows the key affordance while the
* primary-key flag, and with it schema-generation semantics, stays untouched.
*/
export function uniqueKeyModelColumns(databaseType: DatabaseType | undefined, indexes: readonly IndexInfo[] = []): Set<string> {
if (!isUniqueKeyModelDatabase(databaseType)) return new Set();
const uniqueKeyIndex = indexes.find((index) => isUniqueKeyModelIndex(index));
return new Set((uniqueKeyIndex?.columns ?? []).map((name) => name.toLowerCase()));
}
/** Key columns the engine uses for row identity; no synthetic fallbacks, unlike editable row identifiers. */
export function physicalTablePrimaryKeys(
columns: readonly Pick<ColumnInfo, "name" | "is_primary_key">[],
indexes: readonly Pick<IndexInfo, "columns" | "is_primary" | "is_unique" | "index_type" | "name">[] = [],
databaseType?: DatabaseType,
): string[] {
const columnPrimaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (columnPrimaryKeys.length > 0) return columnPrimaryKeys;
const primaryIndex = indexes.find((index) => index.is_primary && index.columns.length > 0);
if (primaryIndex) return primaryIndex.columns;
if (databaseType === "doris" || databaseType === "starrocks") {
const uniqueKeyModelIndex = indexes.find((index) => (index.is_primary || index.is_unique || index.index_type?.toUpperCase() === "UNIQUE KEY" || index.name?.toUpperCase() === "UNIQUE KEY") && index.columns.length > 0);
if (uniqueKeyModelIndex) return uniqueKeyModelIndex.columns;
}
return [];
if (!isUniqueKeyModelDatabase(databaseType)) return [];
return indexes.find((index) => (index.is_unique || index.is_primary) && isUniqueKeyModelIndex(index))?.columns ?? [];
}
export function editableRowIdentifierColumns(databaseType: DatabaseType | undefined, columns: ColumnInfo[], indexes?: IndexInfo[], tableType?: string): string[] {
@@ -55,18 +94,11 @@ export function editableRowIdentifierColumns(databaseType: DatabaseType | undefi
const oracleRowIdFallback = getDatabaseCapability(databaseType).syntheticKey === "oracle-rowid" && primaryKeys.length === 1 && primaryKeys[0]?.toUpperCase() === DBX_ROWID_COLUMN;
if (primaryKeys.length > 0 && !oracleRowIdFallback) return primaryKeys;
const columnByName = new Map(columns.map((column) => [column.name.toLowerCase(), column]));
const isUniqueKeyModel = databaseType === "doris" || databaseType === "starrocks";
const isUniqueKeyModel = isUniqueKeyModelDatabase(databaseType);
const uniqueIndex = indexes
?.filter((index) => {
if (index.filter?.trim() || index.columns.length === 0 || (!index.is_primary && !index.is_unique) || index.key_is_expression?.some(Boolean)) return false;
return index.columns.every((name) => columnByName.has(name.toLowerCase()) && (isUniqueKeyModel || columnByName.get(name.toLowerCase())?.is_nullable === false));
})
?.filter((index) => indexIdentifiesSingleRow(databaseType, index, columnByName))
.sort((left, right) => {
if (isUniqueKeyModel) {
const leftIsUniqueKey = left.index_type?.toUpperCase() === "UNIQUE KEY" || left.name?.toUpperCase() === "UNIQUE KEY" || left.is_primary;
const rightIsUniqueKey = right.index_type?.toUpperCase() === "UNIQUE KEY" || right.name?.toUpperCase() === "UNIQUE KEY" || right.is_primary;
if (leftIsUniqueKey !== rightIsUniqueKey) return Number(rightIsUniqueKey) - Number(leftIsUniqueKey);
}
if (isUniqueKeyModel && isUniqueKeyModelIndex(left) !== isUniqueKeyModelIndex(right)) return Number(isUniqueKeyModelIndex(right)) - Number(isUniqueKeyModelIndex(left));
return Number(right.is_primary) - Number(left.is_primary) || left.columns.length - right.columns.length;
})[0];
return uniqueIndex?.columns.map((name) => columnByName.get(name.toLowerCase())?.name ?? name) ?? primaryKeys;