fix(doris): keep UNIQUE KEY columns unique-only and scope the nullable row id
This commit is contained in:
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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user