fix(query): match primary keys by dialect-aware casing
This commit is contained in:
6 files changed
+502
-51
No files matched your search
@@ -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]!);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user