diff --git a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts index 1b0f74eb32..772ec5685c 100644 --- a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes } from "@/lib/sql/editableQueryHiddenKeys"; -import { analyzeEditableQueryEditability, sourceColumnsForResult } from "@/lib/sql/sqlAnalysis"; +import { analyzeEditableQueryEditability, allPrimaryKeysPresent, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sql/sqlAnalysis"; describe("editable query hidden primary keys", () => { it("appends quoted MySQL primary keys without changing the visible projection", () => { @@ -216,6 +216,35 @@ describe("editable query hidden primary keys", () => { expect(analyzeEditableQueryEditability("select id from jobs union select id from archived_jobs")).toEqual({ editable: false, reason: "set-operation" }); }); + it("maps a qualified Oracle ROWID projection to the synthetic row key", () => { + const analysis: EditableQueryInfo = { + schema: "APP", + tableName: "USERS", + selectStar: false, + columns: [ + { sourceName: "ROWID", sourceQualifier: "t", sourceKey: "t:0", resultName: "ROWID", expression: "t.ROWID" }, + { sourceName: "NAME", sourceQualifier: "t", sourceKey: "t:0", resultName: "NAME", expression: "t.NAME" }, + { sourceName: "LABEL", sourceQualifier: "o", sourceKey: "o:1", resultName: "LABEL", expression: "o.LABEL" }, + ], + }; + + expect(allPrimaryKeysPresent(["__DBX_ROWID"], ["ROWID", "NAME", "LABEL"], analysis, "t:0", "oracle")).toBe(true); + expect(sourceColumnsForResult(analysis, ["ROWID", "NAME", "LABEL"], "t:0", "oracle", ["__DBX_ROWID"])).toEqual(["__DBX_ROWID", "NAME", undefined]); + expect(allPrimaryKeysPresent(["__DBX_ROWID"], ["ROWID", "NAME", "LABEL"], analysis, "o:1", "oracle")).toBe(false); + }); + + it("does not assign an unqualified multi-source ROWID to a target table", () => { + const analysis: EditableQueryInfo = { + schema: "APP", + tableName: "USERS", + selectStar: false, + columns: [{ sourceName: "ROWID", resultName: "ROWID", expression: "ROWID" }], + }; + + expect(allPrimaryKeysPresent(["__DBX_ROWID"], ["ROWID"], analysis, "t:0", "oracle")).toBe(false); + expect(sourceColumnsForResult(analysis, ["ROWID"], "t:0", "oracle", ["__DBX_ROWID"])).toEqual([undefined]); + }); + it("resolves appended aliases to result indexes", () => { expect( hiddenResultColumnIndexes( diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index d43a2e9187..c51e34795b 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -1,3 +1,6 @@ +import type { DatabaseType } from "@/types/database"; +import { DBX_ROWID_COLUMN } from "@/lib/table/tableEditing"; + // Binary column types that should not be edited inline export const BINARY_TYPES = new Set(["blob", "clob", "bytea", "varbinary", "binary", "image", "longblob", "mediumblob", "tinyblob", "blob sub_type 2004", "blob sub_type 2005"]); @@ -812,12 +815,13 @@ function escapeRegExp(value: string): string { * 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 { +export function allPrimaryKeysPresent(primaryKeys: string[], resultColumns: string[], analysis?: EditableQueryInfo, sourceKey?: string, databaseType?: DatabaseType): boolean { if (analysis && !analysis.selectStar) { const sourceColumns = new Set( analysis.columns.flatMap((column) => { if (!column.sourceName) return []; if (sourceKey && column.sourceKey !== sourceKey) return []; + if (databaseType === "oracle" && !column.sourceNameQuoted && column.sourceName.toUpperCase() === "ROWID" && column.sourceKey === sourceKey) return [DBX_ROWID_COLUMN, column.sourceName]; return [column.sourceName]; }), ); @@ -850,12 +854,15 @@ export function allEditableColumnsWriteable(analysis: EditableQueryInfo, resultC return !!matchedColumns && matchedColumns.every((source) => !sourceKey || !source.sourceName || source.sourceKey === sourceKey); } -export function sourceColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[], sourceKey?: string): Array | undefined { +export function sourceColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[], sourceKey?: string, databaseType?: DatabaseType, primaryKeys?: readonly string[]): Array | undefined { if (analysis.selectStar) return undefined; const matchedColumns = matchColumnsForResult(analysis, resultColumns); if (!matchedColumns) return undefined; return matchedColumns.map((column) => { if (sourceKey && column.sourceKey !== sourceKey) return undefined; + if (databaseType === "oracle" && !column.sourceNameQuoted && column.sourceName?.toUpperCase() === "ROWID" && column.sourceKey === sourceKey) { + return primaryKeys?.length === 1 && primaryKeys[0] === DBX_ROWID_COLUMN ? DBX_ROWID_COLUMN : undefined; + } return column.sourceName; }); } diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index 1082ac2e09..c2eadb310e 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -557,6 +557,61 @@ describe("queryStore hidden primary key editing", () => { } }); + it("uses a qualified Oracle ROWID from a joined query as the synthetic row key", async () => { + getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 }); + getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, tableName: string) => + tableName === "USERS" + ? [ + { name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + { name: "NAME", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ] + : [{ name: "LABEL", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }], + ); + lookupLocalCompletionTables.mockReturnValue([ + { name: "USERS", type: "table", schema: "APP" }, + { name: "ORDERS", type: "table", schema: "APP" }, + ]); + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: "APP", + tableName: "USERS", + selectStar: false, + sources: [ + { key: "t:0", schema: "APP", tableName: "USERS", alias: "t" }, + { key: "o:1", schema: "APP", tableName: "ORDERS", alias: "o" }, + ], + columns: [ + { sourceName: "ROWID", sourceQualifier: "t", sourceKey: "t:0", resultName: "ROWID", expression: "t.ROWID" }, + { sourceName: "NAME", sourceQualifier: "t", sourceKey: "t:0", resultName: "NAME", expression: "t.NAME" }, + { sourceName: "LABEL", sourceQualifier: "o", sourceKey: "o:1", resultName: "LABEL", expression: "o.LABEL" }, + ], + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["ROWID", "NAME", "LABEL"], + rows: [["AAAPr9AAEAAAACXAAA", "Alice", "Order"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("oracle-1", "ORCL", "Query"); + store.setAutoCommit(tabId, true); + + await store.executeTabSql(tabId, "SELECT t.ROWID, t.NAME, o.LABEL FROM APP.USERS t LEFT JOIN APP.ORDERS o ON o.USER_ID = t.ID"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.result?.columns).toEqual(["ROWID", "NAME", "LABEL"])); + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["__DBX_ROWID", "NAME", undefined]), { timeout: 5000 }); + expect(tab.tableMeta?.primaryKeys).toEqual(["__DBX_ROWID"]); + expect(tab.queryAnalysis).toBeDefined(); + expect(tab.queryEditabilityReason).toBeUndefined(); + }, 10_000); + it("uses the configured current schema to keep an unqualified Oracle base-table query editable", async () => { getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", default_schema: "APP", query_timeout_secs: 30 }); getColumns.mockResolvedValue([ diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 024a9af138..aa975a80fd 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -657,6 +657,7 @@ function bindColumnsForSource( if (!column.sourceName) return column; if (column.sourceKey) { if (column.sourceKey !== source.key) return column; + if (dbType === "oracle" && !column.sourceNameQuoted && column.sourceName.toUpperCase() === "ROWID") return column; const canonicalName = resolveSourceColumnName(dbType, column.sourceName, column.sourceNameQuoted, tableColumns); return { ...column, sourceName: canonicalName }; } @@ -672,7 +673,7 @@ function bindColumnsForSource( } 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); + if (!analysis.selectStar) return allPrimaryKeysPresent(primaryKeys, resultColumns, analysis, sourceKey, dbType as DatabaseType); const metadataNames = tableColumns.map((column) => column.name); const canonicalResultColumns = resultColumns.flatMap((column) => { const canonicalName = resolveMetadataColumnName(dbType, column, undefined, metadataNames); @@ -4791,9 +4792,15 @@ export const useQueryStore = defineStore("query", () => { return loadedEditableSourceFromMetadata(target, loadedMetadata.metadata); } - function missingPrimaryKeysForSource(primaryKeys: string[], analysis: EditableQueryInfo, sourceKey: string): string[] { + function missingPrimaryKeysForSource(databaseType: DatabaseType, 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] : []))); + const selectedColumns = new Set( + analysis.columns.flatMap((column) => { + if (!column.sourceName || column.sourceKey !== sourceKey) return []; + if (databaseType === "oracle" && !column.sourceNameQuoted && column.sourceName.toUpperCase() === "ROWID") return [DBX_ROWID_COLUMN, column.sourceName]; + return [column.sourceName]; + }), + ); return primaryKeys.filter((primaryKey) => !selectedColumns.has(primaryKey)); } @@ -4847,7 +4854,10 @@ export const useQueryStore = defineStore("query", () => { const metadataAnalysis = expandStarProjectionColumnsForSource(bindColumnsForSource(databaseType, loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns); const oracleLobPreview = databaseType === "oracle" && primaryKeys.length > 0 && oracleRowIdIsSafeForQuery(tab, loaded) && oracleColumnsAllowDeferredLobMarkers(loaded.tableMeta.columns) && oracleQueryProjectsDeferredLob(metadataAnalysis, loaded.source.key, loaded.tableMeta.columns); const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [], oracleLobPreview }; - const missingPrimaryKeys = declaredPrimaryKeys.length === 0 ? primaryKeys : missingPrimaryKeysForSource(primaryKeys, metadataAnalysis, loaded.source.key); + const missingPrimaryKeys = + declaredPrimaryKeys.length === 0 + ? primaryKeys.filter((primaryKey) => !(databaseType === "oracle" && primaryKey === DBX_ROWID_COLUMN && metadataAnalysis.columns.some((column) => column.sourceKey === loaded.source.key && !column.sourceNameQuoted && column.sourceName?.toUpperCase() === "ROWID"))) + : missingPrimaryKeysForSource(databaseType, primaryKeys, metadataAnalysis, loaded.source.key); if (missingPrimaryKeys.length === 0) return unchanged; 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)); @@ -5095,7 +5105,7 @@ export const useQueryStore = defineStore("query", () => { .map((loaded) => { 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 sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result!.columns, loaded.source.key, dbType as DatabaseType, primaryKeys); 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); @@ -5117,7 +5127,7 @@ export const useQueryStore = defineStore("query", () => { 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 displaySourceInfo = resolveResultColumnInfo(dbType, analysis, tab.result.columns, loadedSources); - const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key); + const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key, dbType as DatabaseType, primaryKeys); if (sourceColumns && syntheticRowIdProjection) { const resultIndex = tab.result.columns.findIndex((column) => column.toLowerCase() === syntheticRowIdProjection.alias.toLowerCase()); if (resultIndex >= 0) sourceColumns[resultIndex] = DBX_ROWID_COLUMN;