diff --git a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts index 7355066629..8b08b3fe56 100644 --- a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts @@ -49,6 +49,21 @@ describe("editable query hidden primary keys", () => { ).toBe('SELECT /*+ INDEX(t IDX_USERS_NAME) */ t.NAME, "ID" AS "__DBX_PK_0"\nFROM USERS t'); }); + it("supports an Oracle ROWID expression for keyless base tables", () => { + expect( + buildQueryWithHiddenPrimaryKeys({ + sql: "SELECT * FROM APP.USERS t WHERE t.ACTIVE = 1", + databaseType: "oracle", + primaryKeys: ["__DBX_ROWID"], + existingResultNames: ["ID", "NAME"], + sourceExpressions: { __DBX_ROWID: "ROWIDTOCHAR(ROWID)" }, + }), + ).toEqual({ + sql: 'SELECT *, ROWIDTOCHAR(ROWID) AS "__DBX_PK_0" FROM APP.USERS t WHERE t.ACTIVE = 1', + projections: [{ sourceName: "__DBX_ROWID", alias: "__DBX_PK_0" }], + }); + }); + it("inserts hidden keys before a trailing line comment", () => { expect( buildQueryWithHiddenPrimaryKeys({ diff --git a/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts b/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts index fb6e0d4459..f47db692da 100644 --- a/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts +++ b/apps/desktop/src/lib/sql/editableQueryHiddenKeys.ts @@ -14,7 +14,7 @@ export interface HiddenPrimaryKeyQuery { projections: HiddenPrimaryKeyProjection[]; } -export function buildQueryWithHiddenPrimaryKeys(options: { sql: string; databaseType: DatabaseType; primaryKeys: string[]; existingResultNames: string[] }): HiddenPrimaryKeyQuery | undefined { +export function buildQueryWithHiddenPrimaryKeys(options: { sql: string; databaseType: DatabaseType; primaryKeys: string[]; existingResultNames: string[]; sourceExpressions?: Record }): HiddenPrimaryKeyQuery | undefined { if (options.primaryKeys.length === 0) return undefined; const dialect = sqlSemanticDialectFor({ databaseType: options.databaseType }); @@ -29,7 +29,7 @@ export function buildQueryWithHiddenPrimaryKeys(options: { sql: string; database usedNames.add(alias.toLowerCase()); return { sourceName, alias }; }); - const expressions = projections.map(({ sourceName, alias }) => `${dialect.quoteIdentifier(sourceName)} AS ${dialect.quoteIdentifier(alias)}`); + const expressions = projections.map(({ sourceName, alias }) => `${options.sourceExpressions?.[sourceName] ?? dialect.quoteIdentifier(sourceName)} AS ${dialect.quoteIdentifier(alias)}`); const sql = appendSelectProjections(options.sql, expressions); return sql ? { sql, projections } : undefined; } diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index 4e99c4011f..fd3181c462 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -6,6 +6,7 @@ const executeQuery = vi.fn(); const analyzeEditableQueryEditability = vi.fn(); const getColumns = vi.fn(); const listIndexes = vi.fn(); +const listObjects = vi.fn(); const getConnectionConfig = vi.fn(); const buildSortedQuerySql = vi.fn(); const buildDataGridCountSql = vi.fn(); @@ -32,6 +33,7 @@ vi.mock("@/lib/backend/api", () => ({ executeQuery, getColumns, listIndexes, + listObjects, prepareQueryPaginationExecutionPlan, saveOpenTabsState: vi.fn().mockResolvedValue(undefined), })); @@ -75,6 +77,7 @@ describe("queryStore hidden primary key editing", () => { { name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, ]); listIndexes.mockResolvedValue([]); + listObjects.mockResolvedValue([]); analyzeEditableQueryEditability.mockImplementation(async (sql: string) => queryAnalysis(sql)); buildSortedQuerySql.mockImplementation(async (options) => ({ ok: true, sql: `${options.originalSql} ORDER BY ${options.column} ${options.direction.toUpperCase()}` })); buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*) FROM `users`"); @@ -120,6 +123,93 @@ describe("queryStore hidden primary key editing", () => { expect(tab.queryEditabilityReason).toBeUndefined(); }); + it("uses a hidden Oracle ROWID to keep keyless base-table query results editable", async () => { + getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 }); + getColumns.mockResolvedValue([ + { name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + { name: "PLATFORM", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]); + listObjects.mockResolvedValue([{ name: "TT_PLATFORM_CARS", object_type: "TABLE", schema: "SH_SMCVDMS_OVERSEAS_DRSSITB" }]); + analyzeEditableQueryEditability.mockImplementation(async (sql: string) => { + const hidden = sql.includes("__DBX_PK_0"); + return { + editable: true, + analysis: { + schema: "SH_SMCVDMS_OVERSEAS_DRSSITB", + tableName: "TT_PLATFORM_CARS", + tableAlias: "t", + selectStar: !hidden, + columns: hidden + ? [ + { star: true, sourceQualifier: "t", sourceKey: "t:0", resultName: "*", expression: "t.*" }, + { resultName: "__DBX_PK_0", expression: "ROWIDTOCHAR(ROWID)" }, + ] + : [], + }, + }; + }); + executeMulti.mockResolvedValue([ + { + columns: ["ID", "PLATFORM", "__DBX_PK_0"], + rows: [[72, "轻卡", "AAAPr9AAEAAAACXAAA"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("oracle-1", "ORCL", "Query"); + + await store.executeTabSql(tabId, "SELECT t.* FROM SH_SMCVDMS_OVERSEAS_DRSSITB.TT_PLATFORM_CARS t WHERE t.PLATFORM = '轻卡'"); + + expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT t.*, ROWIDTOCHAR(ROWID) AS \"__DBX_PK_0\" FROM SH_SMCVDMS_OVERSEAS_DRSSITB.TT_PLATFORM_CARS t WHERE t.PLATFORM = '轻卡'", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); + const tab = store.tabs.find((item) => item.id === tabId)!; + expect(tab.result?.hidden_column_indexes).toEqual([2]); + await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["ID", "PLATFORM", "__DBX_ROWID"])); + expect(tab.tableMeta?.primaryKeys).toEqual(["__DBX_ROWID"]); + expect(tab.queryAnalysis).toBeDefined(); + expect(tab.queryAnalysis?.allowInsert).toBe(false); + expect(tab.queryEditabilityReason).toBeUndefined(); + }); + + it("does not inject Oracle ROWID into keyless view queries", async () => { + getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 }); + getColumns.mockResolvedValue([ + { name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + { name: "PLATFORM", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ]); + listIndexes.mockResolvedValue([]); + listObjects.mockResolvedValue([{ name: "PLATFORM_VIEW", object_type: "VIEW", schema: "APP" }]); + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: "APP", + tableName: "PLATFORM_VIEW", + selectStar: true, + columns: [], + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["ID", "PLATFORM"], + rows: [[72, "轻卡"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("oracle-1", "ORCL", "Query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.tableMeta = { schema: "APP", tableName: "PLATFORM_VIEW", tableType: "VIEW", columns: [], primaryKeys: [] }; + + await store.executeTabSql(tabId, "SELECT * FROM APP.PLATFORM_VIEW"); + + expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT * FROM APP.PLATFORM_VIEW", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); + expect(tab.result?.hidden_column_indexes).toBeUndefined(); + }); + it("keeps hidden primary keys and editability after database sorting", async () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 3b633e7f4f..8650c814f1 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -28,7 +28,7 @@ import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult"; import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession"; import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable"; import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest"; -import { canUseKeylessRowPredicate, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; +import { canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; @@ -2209,6 +2209,14 @@ export const useQueryStore = defineStore("query", () => { return primaryKeys.filter((primaryKey) => !selectedColumns.has(primaryKey.toLowerCase())); } + async function oracleRowIdIsSafeForQuery(tab: QueryTab, loaded: LoadedEditableSource): Promise { + const knownType = loaded.tableMeta.tableType?.trim().toUpperCase(); + if (knownType) return knownType === "TABLE"; + const objects = await api.listObjects(tab.connectionId!, tab.database, loaded.tableMeta.schema ?? "", ["TABLE", "VIEW", "MATERIALIZED_VIEW"], loaded.tableMeta.tableName, 20, 0, loaded.tableMeta.catalog); + const matching = objects.find((object) => object.name.toLowerCase() === loaded.tableMeta.tableName.toLowerCase()); + return matching?.object_type.trim().toUpperCase() === "TABLE"; + } + async function prepareEditableQueryExecution(tab: QueryTab, sql: string, conn: ConnectionConfig | undefined, databaseType: DatabaseType | undefined, traceId: string, elapsed: () => string): Promise { const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [] }; if (!databaseType || !HIDDEN_QUERY_KEY_DATABASE_TYPES.has(databaseType) || !tab.connectionId || !tab.database) return unchanged; @@ -2218,27 +2226,32 @@ export const useQueryStore = defineStore("query", () => { if (!editability.editable || !editability.analysis) return unchanged; const analysis = editability.analysis; const sources = editableQuerySources(analysis); - if (sources.length !== 1 || analysis.distinct || analysis.selectStar) return unchanged; + if (sources.length !== 1 || analysis.distinct) return unchanged; 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); - // Hidden row identifiers are limited to declared primary keys. Unique - // indexes, views, and synthetic ROWID fallbacks keep the prior behavior. - const primaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name); - if (primaryKeys.length === 0) return unchanged; + 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 + // ROWID from a view can fail with ORA-01445. + if (databaseType === "oracle" && declaredPrimaryKeys.length === 0 && !(await oracleRowIdIsSafeForQuery(tab, loaded))) return unchanged; + const primaryKeys = editablePrimaryKeys(databaseType, loaded.tableMeta.columns, loaded.tableMeta.tableType); - const missingPrimaryKeys = missingPrimaryKeysForSource(primaryKeys, metadataAnalysis, loaded.source.key); + 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.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName.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())); if (!hasWritableProjection) return unchanged; const rewritten = buildQueryWithHiddenPrimaryKeys({ sql, databaseType, primaryKeys: missingPrimaryKeys, - existingResultNames: metadataAnalysis.columns.map((column) => column.resultName), + existingResultNames: metadataAnalysis.selectStar ? loaded.tableMeta.columns.map((column) => column.name) : metadataAnalysis.columns.map((column) => column.resultName), + sourceExpressions: databaseType === "oracle" && missingPrimaryKeys.includes(DBX_ROWID_COLUMN) ? { [DBX_ROWID_COLUMN]: "ROWIDTOCHAR(ROWID)" } : undefined, }); if (!rewritten) return unchanged; console.info("[DBX][executeTabSql:hidden-primary-keys]", { @@ -2256,7 +2269,7 @@ export const useQueryStore = defineStore("query", () => { } } - async function buildQueryMetadataPatch(tab: QueryTab, sql: string, traceId?: string, elapsed?: () => string): Promise { + async function buildQueryMetadataPatch(tab: QueryTab, sql: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise { if (tab.mode !== "query") return; if (!tab.result || !tab.result.columns.length) { return { @@ -2330,7 +2343,13 @@ 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 primaryKeys = loaded.tableMeta.primaryKeys; + 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); + if (sourceColumns && syntheticRowIdProjection) { + const resultIndex = tab.result.columns.findIndex((column) => column.toLowerCase() === syntheticRowIdProjection.alias.toLowerCase()); + if (resultIndex >= 0) sourceColumns[resultIndex] = DBX_ROWID_COLUMN; + } if (primaryKeys.length === 0 && !canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys)) { return { queryAnalysis: undefined, @@ -2340,7 +2359,8 @@ export const useQueryStore = defineStore("query", () => { }; } - if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key)) { + const primaryKeysPresent = syntheticRowIdProjection ? sourceColumns?.some((column) => column?.toUpperCase() === DBX_ROWID_COLUMN) === true : allPrimaryKeysPresent(primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key); + if (!primaryKeysPresent) { return { queryAnalysis: undefined, querySourceColumns: undefined, @@ -2360,9 +2380,9 @@ export const useQueryStore = defineStore("query", () => { return { queryAnalysis: metadataAnalysis, - querySourceColumns: sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key), + querySourceColumns: sourceColumns, queryEditabilityReason: undefined, - tableMeta: loaded.tableMeta, + tableMeta: primaryKeys === loaded.tableMeta.primaryKeys ? loaded.tableMeta : { ...loaded.tableMeta, primaryKeys }, }; } @@ -2412,7 +2432,7 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === tabId); if (!tab || tab.result !== result) return; console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() }); - const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed); + const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed, hiddenPrimaryKeys); if (patch?.queryAnalysis && hiddenPrimaryKeys.length > 0) { patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false }; }