From b84d16204982b1f61bfd4ab01c1ef45ff36e46f2 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 9 Sep 2026 22:35:51 +0800 Subject: [PATCH] fix(oracle): stop ROWID pagination injection on views --- .../__tests__/useDataGridActions.spec.ts | 36 +++++++++++++ .../useSidebarDataOpenRuntime.spec.ts | 52 +++++++++++++++++++ .../src/composables/useDataGridActions.ts | 21 ++++++++ .../composables/useSidebarDataOpenRuntime.ts | 12 ++++- .../lib/__tests__/table/tableEditing.spec.ts | 4 ++ apps/desktop/src/lib/table/tableEditing.ts | 12 ++++- .../queryStore.hiddenPrimaryKey.spec.ts | 2 +- apps/desktop/src/stores/queryStore.ts | 31 +++++++++-- .../dbx-core/src/sql_dialect/table_select.rs | 11 ++-- crates/dbx-core/src/sql_dialect/tests.rs | 23 +++++++- packages/app-tests/tableEditing.test.ts | 9 ++-- 11 files changed, 196 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts index f5d167df19..4b86a645a8 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridActions.spec.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ setTableMeta: vi.fn(), clearInvalidDataTabSort: vi.fn(), activeResultExecutionTarget: vi.fn(), + lookupLocalCompletionTables: vi.fn(), metadataGeneration: 0, })); @@ -47,6 +48,7 @@ vi.mock("@/stores/connectionStore", () => ({ getConfig: mocks.getConfig, ensureConnected: mocks.ensureConnected, metadataGenerationFor: () => mocks.metadataGeneration, + lookupLocalCompletionTables: mocks.lookupLocalCompletionTables, }), })); @@ -145,6 +147,7 @@ describe("useDataGridActions", () => { mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" }); mocks.ensureConnected.mockResolvedValue(undefined); mocks.activeResultExecutionTarget.mockReturnValue(undefined); + mocks.lookupLocalCompletionTables.mockReturnValue([]); mocks.getColumns.mockResolvedValue([{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]); mocks.listIndexes.mockResolvedValue([]); }); @@ -167,6 +170,39 @@ describe("useDataGridActions", () => { expect(mocks.executeTabSql.mock.calls[0]?.[2]).not.toHaveProperty("preserveTotalRowCountDuringExecution"); }); + it("repairs a restored Oracle view type before building paginated SQL", async () => { + mocks.getConfig.mockReturnValue({ id: "oracle-1", db_type: "oracle", default_schema: "REPORTING" }); + mocks.lookupLocalCompletionTables.mockReturnValue([{ name: "REPORT_ROWS", schema: "REPORTING", type: "view" }]); + mocks.buildTableSelectSql.mockResolvedValueOnce('SELECT "ID" FROM "REPORTING"."REPORT_ROWS"'); + const tab = tableDataTab({ + connectionId: "oracle-1", + database: "XEPDB1", + title: "REPORT_ROWS", + tableMeta: { + schema: "REPORTING", + tableName: "REPORT_ROWS", + tableType: "TABLE", + columns: [{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: ["__DBX_ROWID"], + }, + }); + mocks.tabs.push(tab); + const actions = useDataGridActions(computed(() => tab)); + + await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 100, "refresh"); + + expect(tab.tableMeta?.tableType).toBe("VIEW"); + expect(mocks.buildTableSelectSql).toHaveBeenCalledWith( + expect.objectContaining({ + tableName: "REPORT_ROWS", + tableType: "VIEW", + includeRowId: false, + limit: 100, + offset: 100, + }), + ); + }); + it("executes the WHERE apply SQL on the emitting tab (#8216)", async () => { // ContentArea 的 executeSql 事件契约是 (tabId, sql),App.vue 按此顺序透传; // 签名若仍是 sql-first,resolveActionTab 会拿 SQL 文本当 tab id 匹配失败, diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index 595a12a927..d14be074be 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -565,6 +565,58 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.loadTableMetadata).not.toHaveBeenCalled(); }); + it("repairs a restored Oracle view tab that was persisted as a table", async () => { + mocks.databaseType = "oracle"; + const viewNode = { ...tableNode, id: "view-users", type: "view" as const, tableType: undefined }; + mocks.tabs.push({ + id: "existing-view-tab", + connectionId: "connection-1", + database: "app", + title: "users", + mode: "data", + schema: "public", + sql: "SELECT * FROM users", + isDirty: false, + isExecuting: true, + executionId: "running-query", + isCancelling: false, + isExplaining: false, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + primaryKeys: ["__DBX_ROWID"], + }, + tableMetaUpdatedAt: Date.now(), + tableMetaGeneration: 0, + } as QueryTab); + mocks.loadTableMetadata.mockImplementationOnce(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({ + metadata: { + schema: request.schema, + tableName: request.tableName, + tableType: request.tableType, + database: request.database, + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + indexes: [], + primaryKeys: [], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + })); + + await useSidebarDataOpenRuntime().openData(viewNode); + + expect(mocks.tabs[0]?.tableMeta?.tableType).toBe("VIEW"); + expect(mocks.cancelTabExecution).not.toHaveBeenCalled(); + expect(mocks.executeTabSql).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(mocks.loadTableMetadata).toHaveBeenCalledWith(expect.objectContaining({ tableName: "users", tableType: "VIEW" })); + expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual([]); + }); + }); + it("does not mark row identity pending on a warm metadata cache", async () => { mocks.cachedMetadata = { metadata: { diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 099b10b288..5a0c69968f 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -77,6 +77,26 @@ export function useDataGridActions(activeTab: ComputedRef) return tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); } + function reconcileOracleTableType(tab: QueryTab): void { + const config = connectionStore.getConfig(tab.connectionId); + const databaseType = effectiveDatabaseTypeForConnection(config); + if (databaseType !== "oracle" && databaseType !== "oceanbase-oracle") return; + const tableMeta = tab.tableMeta; + if (!tableMeta?.tableName) return; + + const normalize = (value: string | undefined) => value?.trim().toLowerCase() ?? ""; + const resolvedSchema = tableMeta.schema?.trim() || config?.default_schema?.trim(); + const matches = connectionStore + .lookupLocalCompletionTables(tab.connectionId, tableMeta.database ?? tab.database, tableMeta.tableName, 20, resolvedSchema, tableMeta.catalog) + .filter((candidate) => normalize(candidate.name) === normalize(tableMeta.tableName) && normalize(candidate.schema) === normalize(resolvedSchema) && normalize(candidate.catalog) === normalize(tableMeta.catalog)); + if (matches.length !== 1) return; + + const objectType = matches[0]?.type; + const resolvedTableType = objectType === "view" ? "VIEW" : objectType === "materialized_view" ? "MATERIALIZED_VIEW" : objectType === "table" ? "TABLE" : undefined; + if (!resolvedTableType || tableMeta.tableType?.trim().toUpperCase() === resolvedTableType) return; + queryStore.setTableMeta(tab.id, { ...tableMeta, tableType: resolvedTableType }); + } + function resultSortPagination(tab: QueryTab): { limit: number; offset: number } | undefined { const limit = tab.resultPageLimit; return typeof limit === "number" && limit > 0 ? { limit, offset: 0 } : undefined; @@ -193,6 +213,7 @@ export function useDataGridActions(activeTab: ComputedRef) const startedAt = performance.now(); const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`; if (tab.mode === "data" && tableMetaForDataTab(tab)) { + reconcileOracleTableType(tab); tab.whereInput = whereInput ?? ""; queryStore.clearInvalidDataTabSort(tab.id); const realColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? []; diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 4b953e1735..b47362197a 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -174,17 +174,25 @@ export function useSidebarDataOpenRuntime() { if (existingSameTableTab && (existingSameTableTab.isExecuting || canActivateExistingDataTableTab(existingSameTableTab, { activateExecuting: false }))) { if (node.comment !== undefined) existingSameTableTab.tableComment = node.comment; + const previousTableType = existingSameTableTab.tableMeta?.tableType?.trim().toUpperCase(); + const tableTypeChanged = !!existingSameTableTab.tableMeta && previousTableType !== tableType; + if (tableTypeChanged) { + queryStore.setTableMeta(existingSameTableTab.id, { + ...existingSameTableTab.tableMeta!, + tableType, + }); + } queryStore.switchTab(existingSameTableTab.id); logPhase("existing-tab-activated", { table: node.label }); // 代次失配视同冷缓存(即使位于 30s TTL 窗口内也要重建):disconnect / // 关库 / 死池重连都可能不改变 timestamp 判定而改变连接生命周期 const connectionGeneration = connectionStore.metadataGenerationFor(node.connectionId, node.database); - if (isDataTabMetadataLifecycleStale(existingSameTableTab, connectionGeneration) || dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) { + if (tableTypeChanged || isDataTabMetadataLifecycleStale(existingSameTableTab, connectionGeneration) || dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) { // 真实列缺失时行标识未知:启动刷新的同时必须挂起编辑门控(所有 // "真实列缺失且启动刷新"的入口统一置 pending) if (!existingSameTableTab.tableMeta?.columns.length) existingSameTableTab.tableMetaPending = true; void refreshTableMetaInBackground(existingSameTableTab.id, true); - logPhase("metadata-started", { tabId: existingSameTableTab.id, reason: "existing-tab-stale" }); + logPhase("metadata-started", { tabId: existingSameTableTab.id, reason: tableTypeChanged ? "object-type-changed" : "existing-tab-stale" }); } return; } diff --git a/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts b/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts index 1461819c1a..fb1fbb6c91 100644 --- a/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts @@ -42,6 +42,7 @@ function index(columns: string[], isUnique = true, filter: string | null = null) describe("tableEditing", () => { it("synthesizes ROWID only for Oracle-compatible base tables", () => { + expect(editablePrimaryKeys("oracle", [column("ID"), column("NAME")])).toEqual([]); expect(editablePrimaryKeys("oracle", [column("ID"), column("NAME")], "VIEW")).toEqual([]); expect(editablePrimaryKeys("oracle", [column("ID"), column("NAME")], "TABLE")).toEqual([DBX_ROWID_COLUMN]); expect(editablePrimaryKeys("oceanbase-oracle", [column("ID"), column("NAME")], "TABLE")).toEqual([DBX_ROWID_COLUMN]); @@ -82,6 +83,9 @@ describe("tableEditing", () => { }); it("does not include Oracle ROWID for view data tabs", () => { + expect(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN])).toBe(true); + expect(shouldIncludeSyntheticRowId("oracle", [DBX_ROWID_COLUMN])).toBe(false); + expect(shouldIncludeSyntheticRowId("oracle", [DBX_ROWID_COLUMN], "TABLE")).toBe(true); expect(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN], "VIEW")).toBe(false); expect(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN], "MATERIALIZED_VIEW")).toBe(false); expect(usesSyntheticRowIdKey("oceanbase-oracle", [DBX_ROWID_COLUMN], "TABLE")).toBe(true); diff --git a/apps/desktop/src/lib/table/tableEditing.ts b/apps/desktop/src/lib/table/tableEditing.ts index d8a3284fd6..1d5b817246 100644 --- a/apps/desktop/src/lib/table/tableEditing.ts +++ b/apps/desktop/src/lib/table/tableEditing.ts @@ -9,6 +9,10 @@ function isViewTableType(tableType?: string): boolean { return tableType?.toUpperCase().includes("VIEW") === true; } +function isKnownOracleBaseTableType(tableType?: string): boolean { + return tableType?.trim().toUpperCase() === "TABLE"; +} + export function isTdengineStableTableType(tableType?: string): boolean { const normalized = tableType?.trim().toUpperCase(); return normalized === "STABLE" || normalized === "SUPER TABLE" || normalized === "SUPERTABLE"; @@ -19,7 +23,8 @@ export function editablePrimaryKeys(databaseType: DatabaseType | undefined, colu if (isViewTableType(tableType)) return primaryKeys; if (databaseType === "tdengine" && primaryKeys.length > 0 && isTdengineStableTableType(tableType)) return [DBX_TDENGINE_TBNAME_COLUMN, ...primaryKeys]; const syntheticKey = getDatabaseCapability(databaseType).syntheticKey; - if ((syntheticKey === "oracle-rowid" || syntheticKey === "xugu-rowid") && primaryKeys.length === 0) return [DBX_ROWID_COLUMN]; + if (syntheticKey === "oracle-rowid" && primaryKeys.length === 0 && isKnownOracleBaseTableType(tableType)) return [DBX_ROWID_COLUMN]; + if (syntheticKey === "xugu-rowid" && primaryKeys.length === 0) return [DBX_ROWID_COLUMN]; if (syntheticKey === "neo4j-element-id" && primaryKeys.length === 0) return [DBX_NEO4J_ELEMENT_ID_COLUMN]; return primaryKeys; } @@ -93,7 +98,9 @@ export function hiveTablePropertiesIndicateTransactional(result: { rows: readonl export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean { if (isViewTableType(tableType)) return false; const syntheticKey = getDatabaseCapability(databaseType).syntheticKey; - return primaryKeys.length === 1 && (((syntheticKey === "oracle-rowid" || syntheticKey === "xugu-rowid") && primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN) || (syntheticKey === "neo4j-element-id" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN)); + if (primaryKeys.length !== 1) return false; + if (syntheticKey === "oracle-rowid" || syntheticKey === "xugu-rowid") return primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN; + return syntheticKey === "neo4j-element-id" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN; } /** @@ -103,6 +110,7 @@ export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, pr */ export function shouldIncludeSyntheticRowId(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean { if (isViewTableType(tableType)) return false; + if (getDatabaseCapability(databaseType).syntheticKey === "oracle-rowid" && !isKnownOracleBaseTableType(tableType)) return false; if (usesSyntheticRowIdKey(databaseType, primaryKeys, tableType)) return true; return databaseType === "xugu" && primaryKeys.length === 0; } diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index bbb7e3c250..d343ba59b6 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -773,7 +773,7 @@ describe("queryStore hidden primary key editing", () => { expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT * FROM CUSTOMER_VIEW", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 })); const tab = store.tabs.find((item) => item.id === tabId)!; expect(tab.result?.hidden_column_indexes).toBeUndefined(); - await vi.waitFor(() => expect(tab.queryEditabilityReason).toBe("primary-key-not-returned")); + await vi.waitFor(() => expect(tab.queryEditabilityReason).toBe("no-primary-key")); expect(tab.queryAnalysis).toBeUndefined(); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 4984f65d43..8f25d53f6e 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4676,6 +4676,30 @@ export const useQueryStore = defineStore("query", () => { oracleLobPreview: boolean; } + function oracleCompletionTableType(tab: QueryTab, metadataDbType: string, database: string, schema: string, tableName: string, catalog?: string): string | undefined { + if (metadataDbType !== "oracle" && metadataDbType !== "oceanbase-oracle") return undefined; + const resolvedSchema = schema.trim(); + if (!resolvedSchema) return undefined; + const normalizeIdentifier = (value: string | undefined) => value?.trim().toLowerCase() ?? ""; + const targetName = normalizeIdentifier(tableName); + const targetSchema = normalizeIdentifier(resolvedSchema); + const targetCatalog = catalog?.trim() ? normalizeIdentifier(catalog) : undefined; + const matches = useConnectionStore() + .lookupLocalCompletionTables(tab.connectionId!, database, tableName, 20, resolvedSchema, catalog) + .filter((table) => normalizeIdentifier(table.name) === targetName && normalizeIdentifier(table.schema) === targetSchema && (!targetCatalog || normalizeIdentifier(table.catalog) === targetCatalog)); + if (matches.length !== 1) return undefined; + const match = matches[0]!; + return match.tableType?.trim() || match.type?.toUpperCase(); + } + + function canUseQueryKeylessRowPredicate(databaseType: DatabaseType, loaded: LoadedEditableSource): boolean { + if (!canUseKeylessRowPredicate(databaseType, loaded.tableMeta.primaryKeys)) return false; + // An unknown Oracle object may be a view whose query shape rejects ROWID + // and whose rows cannot be mapped safely for writes. Keep the result + // read-only until the object tree or tab metadata confirms its type. + return databaseType !== "oracle" || !!loaded.tableMeta.tableType?.trim(); + } + function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch) { tab.queryAnalysis = patch.queryAnalysis; tab.querySourceColumns = patch.querySourceColumns; @@ -4730,7 +4754,8 @@ export const useQueryStore = defineStore("query", () => { // Keep SQL Server writes unqualified unless the SELECT source explicitly // named a schema, so SELECT and UPDATE resolve the same object. const writeSchema = dbType === "sqlserver" && !source.schema ? undefined : metadataSchema || undefined; - const knownTableType = tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined; + const localTableType = oracleCompletionTableType(tab, metadataDbType, metadataDatabase, metadataSchema || conn?.default_schema || "", metadataTableName, metadataCatalog); + const knownTableType = localTableType ?? (tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined); return { source: metadataSource, analysis: normalizeUppercaseFoldedQueryAnalysis(metadataDbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName), @@ -5129,7 +5154,7 @@ export const useQueryStore = defineStore("query", () => { const primaryKeys = loaded.tableMeta.primaryKeys; 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 keylessAllowed = sources.length === 1 && canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded); const primaryKeySet = new Set(primaryKeys); const editableSourceColumnCount = (sourceColumns ?? []).filter((column) => column && !primaryKeySet.has(column)).length; return { @@ -5154,7 +5179,7 @@ export const useQueryStore = defineStore("query", () => { 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)) { + if (primaryKeys.length === 0 && !canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded)) { return { queryAnalysis: undefined, querySourceColumns: undefined, diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index 7cf5f6e1ed..70b2e4db6a 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -267,11 +267,12 @@ pub fn build_table_data_select_sql_with_database( }; let order_by = options.order_by.as_deref().filter(|order| !order.trim().is_empty()).or(default_order_by.as_deref()); let order = order_by.map(|order_by| format!(" ORDER BY {order_by}")).unwrap_or_default(); - // Oracle join views can raise ORA-01445 when ROWID is selected; keep the - // synthetic ROWID fallback scoped to base-table reads. + // Oracle views with DISTINCT/GROUP BY raise ORA-01446 when ROWID is + // selected. Missing object metadata must therefore fail closed instead of + // being treated as a base table. let include_oracle_row_id = options.include_row_id && uses_oracle_row_id(database_type) - && !is_view_table_type(options.table_type.as_deref()); + && is_oracle_base_table_type(options.table_type.as_deref()); let include_xugu_row_id = options.include_row_id && uses_xugu_row_id(database_type) && !is_view_table_type(options.table_type.as_deref()); let offset = options.offset.unwrap_or(0); @@ -530,6 +531,10 @@ fn is_view_table_type(table_type: Option<&str>) -> bool { table_type.is_some_and(|value| value.to_ascii_uppercase().contains("VIEW")) } +fn is_oracle_base_table_type(table_type: Option<&str>) -> bool { + table_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("TABLE")) +} + pub fn build_table_select_sql(options: TableSelectSqlOptions<'_>) -> String { let database_type = options.database_type; if database_type == Some(DatabaseType::VictoriaMetrics) { diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 97266709fc..83033a2520 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -1304,7 +1304,7 @@ fn builds_oracle_and_neo4j_table_data_queries() { database_type: Some(DatabaseType::Oracle), schema: Some("DBXTEST".to_string()), table_name: "DBX_LOAD_TABLE_006".to_string(), - table_type: None, + table_type: Some("TABLE".to_string()), primary_keys: vec![DBX_ROWID_COLUMN.to_string()], columns: Vec::new(), fallback_order_columns: Vec::new(), @@ -1322,7 +1322,7 @@ fn builds_oracle_and_neo4j_table_data_queries() { database_type: Some(DatabaseType::Oracle), schema: Some("DBXTEST".to_string()), table_name: "DBX_LOAD_TABLE_006".to_string(), - table_type: None, + table_type: Some("TABLE".to_string()), primary_keys: vec![DBX_ROWID_COLUMN.to_string()], columns: vec!["ID".to_string(), "NAME".to_string()], fallback_order_columns: Vec::new(), @@ -1445,6 +1445,25 @@ fn builds_oracle_and_neo4j_table_data_queries() { ); } +#[test] +fn oracle_unknown_table_type_does_not_assume_rowid_support() { + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Oracle), + schema: Some("DBXTEST".to_string()), + table_name: "DBX_DISTINCT_VIEW".to_string(), + table_type: None, + primary_keys: vec![DBX_ROWID_COLUMN.to_string()], + columns: vec!["ID".to_string(), "NAME".to_string()], + limit: Some(100), + offset: Some(100), + include_row_id: true, + ..Default::default() + }), + "SELECT \"ID\", \"NAME\" FROM (SELECT dbx_inner.*, ROWNUM AS \"__dbx_row_num\" FROM (SELECT \"ID\", \"NAME\" FROM \"DBXTEST\".\"DBX_DISTINCT_VIEW\") dbx_inner WHERE ROWNUM <= 200) WHERE \"__dbx_row_num\" > 100" + ); +} + #[test] fn builds_oracle_rowid_wrapped_large_value_reload_sql() { // The data-grid large-value reload selects the synthetic rowid key plus the diff --git a/packages/app-tests/tableEditing.test.ts b/packages/app-tests/tableEditing.test.ts index 2b568ee61b..637764d424 100644 --- a/packages/app-tests/tableEditing.test.ts +++ b/packages/app-tests/tableEditing.test.ts @@ -29,8 +29,8 @@ function column(name: string, isPrimaryKey = false): ColumnInfo { } test("uses ROWID as Oracle editable key when a table has no primary key", () => { - assert.deepEqual(editablePrimaryKeys("oracle", [column("ID"), column("CITY")]), [DBX_ROWID_COLUMN]); - assert.deepEqual(editablePrimaryKeys("oceanbase-oracle", [column("ID"), column("CITY")]), [DBX_ROWID_COLUMN]); + assert.deepEqual(editablePrimaryKeys("oracle", [column("ID"), column("CITY")], "TABLE"), [DBX_ROWID_COLUMN]); + assert.deepEqual(editablePrimaryKeys("oceanbase-oracle", [column("ID"), column("CITY")], "TABLE"), [DBX_ROWID_COLUMN]); }); test("keeps declared primary keys ahead of Oracle ROWID fallback", () => { @@ -151,8 +151,9 @@ test("detects the synthetic Oracle ROWID key case", () => { }); test("hides only the synthetic Oracle ROWID grid column", () => { - assert.equal(isHiddenGridColumn("oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), true); - assert.equal(isHiddenGridColumn("oceanbase-oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), true); + assert.equal(isHiddenGridColumn("oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), false); + assert.equal(isHiddenGridColumn("oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN], "TABLE"), true); + assert.equal(isHiddenGridColumn("oceanbase-oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN], "TABLE"), true); assert.equal(isHiddenGridColumn("oracle", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN], "VIEW"), false); assert.equal(isHiddenGridColumn("oracle", "ROWID", [DBX_ROWID_COLUMN]), false); assert.equal(isHiddenGridColumn("mysql", DBX_ROWID_COLUMN, [DBX_ROWID_COLUMN]), false);