fix(query): match primary keys by dialect-aware casing

This commit is contained in:
zipg authored and GitHub committed 2026-07-15 20:11:04 +08:00
1 parent 2457fac04f
commit 5b985b670d
6 files changed
+502 -51

No files matched your search

+50 -12
View File
@@ -48,6 +48,44 @@ export interface EditableQuerySource {
alias?: string;
}
const POSTGRES_FOLDED_IDENTIFIER_TYPES = new Set(["postgres", "redshift", "gaussdb", "highgo", "vastbase", "kwdb", "opengauss", "questdb"]);
const ORACLE_FOLDED_IDENTIFIER_TYPES = new Set(["oracle", "dameng", "oceanbase-oracle"]);
/**
* Resolve a SQL source identifier to the canonical name returned by table
* metadata. Quoted identifiers are always exact. PostgreSQL-compatible
* unquoted identifiers fold to lower case, while Oracle-compatible identifiers
* fold to upper case. Other dialects may use a case-insensitive match only when
* it identifies exactly one physical column.
*/
export function resolveMetadataColumnName(databaseType: string, sourceName: string, sourceNameQuoted: boolean | undefined, metadataColumns: readonly string[]): string | undefined {
if (sourceNameQuoted) return metadataColumns.find((column) => column === sourceName);
// Result-set labels are already database-resolved names rather than SQL
// source tokens. Preserve exact PostgreSQL/Oracle casing instead of folding
// a physical quoted column such as `"ID"` to `id`.
if (sourceNameQuoted === undefined) {
const exact = metadataColumns.find((column) => column === sourceName);
if (exact || POSTGRES_FOLDED_IDENTIFIER_TYPES.has(databaseType) || ORACLE_FOLDED_IDENTIFIER_TYPES.has(databaseType)) return exact;
const caseOnlyMatches = metadataColumns.filter((column) => column.toLowerCase() === sourceName.toLowerCase());
return caseOnlyMatches.length === 1 ? caseOnlyMatches[0] : undefined;
}
if (POSTGRES_FOLDED_IDENTIFIER_TYPES.has(databaseType)) {
const folded = sourceName.toLowerCase();
return metadataColumns.find((column) => column === folded);
}
if (ORACLE_FOLDED_IDENTIFIER_TYPES.has(databaseType)) {
const folded = sourceName.toUpperCase();
return metadataColumns.find((column) => column === folded);
}
const exact = metadataColumns.find((column) => column === sourceName);
if (exact) return exact;
const caseOnlyMatches = metadataColumns.filter((column) => column.toLowerCase() === sourceName.toLowerCase());
return caseOnlyMatches.length === 1 ? caseOnlyMatches[0] : undefined;
}
export type QueryEditabilityReason = "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable";
export type QueryEditability = { editable: true; analysis: EditableQueryInfo } | { editable: false; reason: QueryEditabilityReason };
@@ -451,7 +489,9 @@ function escapeRegExp(value: string): string {
/**
* Check if all primary key columns are present in the result set columns.
* Comparison is case-insensitive.
* Source names must already be resolved to canonical metadata names. Exact
* comparison prevents `id` from being mistaken for a distinct quoted `"ID"`
* column in PostgreSQL.
*/
export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[], analysis?: EditableQueryInfo, sourceKey?: string): boolean {
if (analysis && !analysis.selectStar) {
@@ -459,26 +499,24 @@ export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: stri
analysis.columns.flatMap((column) => {
if (!column.sourceName) return [];
if (sourceKey && column.sourceKey !== sourceKey) return [];
return [column.sourceName.toLowerCase()];
return [column.sourceName];
}),
);
return primaryKeys.every((pk) => sourceColumns.has(pk.toLowerCase()));
return primaryKeys.every((pk) => sourceColumns.has(pk));
}
const colSet = new Set(resultColumns.map((c) => c.toLowerCase()));
return primaryKeys.every((pk) => colSet.has(pk.toLowerCase()));
const colSet = new Set(resultColumns);
return primaryKeys.every((pk) => colSet.has(pk));
}
function matchColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[]): EditableQueryColumn[] | undefined {
const matches: EditableQueryColumn[] = [];
let searchFrom = 0;
for (const resultColumn of resultColumns) {
const normalized = resultColumn.toLowerCase();
let matchIndex = -1;
for (let index = searchFrom; index < analysis.columns.length; index++) {
if (analysis.columns[index]!.resultName.toLowerCase() === normalized) {
matchIndex = index;
break;
}
let matchIndex = analysis.columns.findIndex((column, index) => index >= searchFrom && column.resultName === resultColumn);
if (matchIndex < 0) {
const normalized = resultColumn.toLowerCase();
const caseOnlyMatches = analysis.columns.flatMap((column, index) => (index >= searchFrom && column.resultName.toLowerCase() === normalized ? [index] : []));
if (caseOnlyMatches.length === 1) matchIndex = caseOnlyMatches[0]!;
}
if (matchIndex < 0) return undefined;
matches.push(analysis.columns[matchIndex]!);
+48 -22
View File
@@ -6,7 +6,7 @@ import type { ConnectionConfig, DatabaseType, ObjectBrowserViewport, QueryResult
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, resolveMetadataColumnName, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys";
import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence";
import {
@@ -250,24 +250,52 @@ function cloneAnalysisForSource(analysis: EditableQueryInfo, source: EditableQue
};
}
function sourceMatchesColumn(columnName: string, tableColumns: readonly { name: string }[]): boolean {
const normalizedColumn = columnName.toLowerCase();
return tableColumns.some((column) => column.name.toLowerCase() === normalizedColumn);
function resolveSourceColumnName(dbType: string, columnName: string, quoted: boolean | undefined, tableColumns: readonly { name: string }[]): string | undefined {
return resolveMetadataColumnName(
dbType,
columnName,
quoted,
tableColumns.map((column) => column.name),
);
}
function bindUnqualifiedColumnsForSource(analysis: EditableQueryInfo, source: EditableQuerySource, tableColumns: readonly { name: string }[], allSourceColumns: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }> = [{ source, columns: tableColumns }]): EditableQueryInfo {
function bindColumnsForSource(
dbType: string,
analysis: EditableQueryInfo,
source: EditableQuerySource,
tableColumns: readonly { name: string }[],
allSourceColumns: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }> = [{ source, columns: tableColumns }],
): EditableQueryInfo {
return {
...analysis,
columns: analysis.columns.map((column) => {
if (!column.sourceName || column.sourceKey || column.sourceQualifier) return column;
if (!sourceMatchesColumn(column.sourceName, tableColumns)) return column;
const matchingSources = allSourceColumns.filter((entry) => sourceMatchesColumn(column.sourceName!, entry.columns));
if (!column.sourceName) return column;
if (column.sourceKey) {
if (column.sourceKey !== source.key) return column;
const canonicalName = resolveSourceColumnName(dbType, column.sourceName, column.sourceNameQuoted, tableColumns);
return { ...column, sourceName: canonicalName };
}
if (column.sourceQualifier) return column;
const matchingSources = allSourceColumns.flatMap((entry) => {
const canonicalName = resolveSourceColumnName(dbType, column.sourceName!, column.sourceNameQuoted, entry.columns);
return canonicalName ? [{ source: entry.source, canonicalName }] : [];
});
if (matchingSources.length !== 1 || matchingSources[0]?.source.key !== source.key) return column;
return { ...column, sourceKey: source.key };
return { ...column, sourceName: matchingSources[0].canonicalName, sourceKey: source.key };
}),
};
}
function primaryKeysPresentForSource(dbType: string, primaryKeys: string[], resultColumns: string[], analysis: EditableQueryInfo, sourceKey: string, tableColumns: readonly { name: string }[]): boolean {
if (!analysis.selectStar) return allPrimaryKeysPresent(primaryKeys, resultColumns, analysis, sourceKey);
const metadataNames = tableColumns.map((column) => column.name);
const canonicalResultColumns = resultColumns.flatMap((column) => {
const canonicalName = resolveMetadataColumnName(dbType, column, undefined, metadataNames);
return canonicalName ? [canonicalName] : [];
});
return allPrimaryKeysPresent(primaryKeys, canonicalResultColumns);
}
function expandStarProjectionColumnsForSource(analysis: EditableQueryInfo, source: EditableQuerySource, tableColumns: readonly { name: string }[]): EditableQueryInfo {
if (analysis.selectStar || !analysis.columns.some((column) => column.star)) return analysis;
return {
@@ -2252,8 +2280,8 @@ export const useQueryStore = defineStore("query", () => {
function missingPrimaryKeysForSource(primaryKeys: string[], analysis: EditableQueryInfo, sourceKey: string): string[] {
if (analysis.selectStar) return [];
const selectedColumns = new Set(analysis.columns.flatMap((column) => (column.sourceName && column.sourceKey === sourceKey ? [column.sourceName.toLowerCase()] : [])));
return primaryKeys.filter((primaryKey) => !selectedColumns.has(primaryKey.toLowerCase()));
const selectedColumns = new Set(analysis.columns.flatMap((column) => (column.sourceName && column.sourceKey === sourceKey ? [column.sourceName] : [])));
return primaryKeys.filter((primaryKey) => !selectedColumns.has(primaryKey));
}
async function oracleRowIdIsSafeForQuery(tab: QueryTab, loaded: LoadedEditableSource): Promise<boolean> {
@@ -2277,7 +2305,7 @@ export const useQueryStore = defineStore("query", () => {
const loaded = await loadEditableQuerySource(tab, analysis, sources[0]!, conn, databaseType, traceId, elapsed);
if (loaded.tableMeta.tableType?.toUpperCase().includes("VIEW")) return unchanged;
const metadataAnalysis = expandStarProjectionColumnsForSource(bindUnqualifiedColumnsForSource(loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns);
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(databaseType, loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns);
const declaredPrimaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name);
// Oracle base tables without declared keys use the same ROWID identity as
// table-data tabs. Confirm the object is a base table because selecting
@@ -2287,10 +2315,8 @@ export const useQueryStore = defineStore("query", () => {
const missingPrimaryKeys = declaredPrimaryKeys.length === 0 ? primaryKeys : missingPrimaryKeysForSource(primaryKeys, metadataAnalysis, loaded.source.key);
if (missingPrimaryKeys.length === 0) return unchanged;
const primaryKeySet = new Set(primaryKeys.map((primaryKey) => primaryKey.toLowerCase()));
const hasWritableProjection = metadataAnalysis.selectStar
? loaded.tableMeta.columns.some((column) => !primaryKeySet.has(column.name.toLowerCase()))
: metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName.toLowerCase()));
const primaryKeySet = new Set(primaryKeys);
const hasWritableProjection = metadataAnalysis.selectStar ? loaded.tableMeta.columns.some((column) => !primaryKeySet.has(column.name)) : metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName));
if (!hasWritableProjection) return unchanged;
const rewritten = buildQueryWithHiddenPrimaryKeys({
@@ -2369,13 +2395,13 @@ export const useQueryStore = defineStore("query", () => {
// source table has a complete row identifier and at least one writable column.
const candidates = loadedSources
.map((loaded) => {
const metadataAnalysis = expandStarProjectionColumnsForSource(bindUnqualifiedColumnsForSource(loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(dbType, loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
const primaryKeys = loaded.tableMeta.primaryKeys;
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result!.columns, loaded.source.key);
const primaryKeysPresent = allPrimaryKeysPresent(primaryKeys, tab.result!.columns, metadataAnalysis, loaded.source.key);
const primaryKeysPresent = primaryKeysPresentForSource(dbType, primaryKeys, tab.result!.columns, metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
const keylessAllowed = sources.length === 1 && canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys);
const primaryKeySet = new Set(primaryKeys.map((key) => key.toLowerCase()));
const editableSourceColumnCount = (sourceColumns ?? []).filter((column) => column && !primaryKeySet.has(column.toLowerCase())).length;
const primaryKeySet = new Set(primaryKeys);
const editableSourceColumnCount = (sourceColumns ?? []).filter((column) => column && !primaryKeySet.has(column)).length;
return {
...loaded,
analysis: metadataAnalysis,
@@ -2389,7 +2415,7 @@ export const useQueryStore = defineStore("query", () => {
if (loadedSources.length === 1) {
const loaded = loadedSources[0]!;
const metadataAnalysis = expandStarProjectionColumnsForSource(bindUnqualifiedColumnsForSource(loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(dbType, loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
const syntheticRowIdProjection = hiddenPrimaryKeys.find((projection) => projection.sourceName.toUpperCase() === DBX_ROWID_COLUMN);
const primaryKeys = loaded.tableMeta.primaryKeys.length === 0 && syntheticRowIdProjection ? [DBX_ROWID_COLUMN] : loaded.tableMeta.primaryKeys;
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key);
@@ -2406,7 +2432,7 @@ export const useQueryStore = defineStore("query", () => {
};
}
const primaryKeysPresent = syntheticRowIdProjection ? sourceColumns?.some((column) => column?.toUpperCase() === DBX_ROWID_COLUMN) === true : allPrimaryKeysPresent(primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key);
const primaryKeysPresent = syntheticRowIdProjection ? sourceColumns?.some((column) => column?.toUpperCase() === DBX_ROWID_COLUMN) === true : primaryKeysPresentForSource(dbType, primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
if (!primaryKeysPresent) {
return {
queryAnalysis: undefined,