From 3e75be87ebd93001efe295dbc5c2a04ad1242ca6 Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:44:30 +0800 Subject: [PATCH] fix(xugu): enable safe row editing without primary keys --- apps/desktop/src/components/grid/DataGrid.vue | 12 ++-- .../DataGridLargeValueReloadSql.spec.ts | 4 +- .../useSidebarDataOpenRuntime.spec.ts | 5 +- .../src/composables/useDataGridActions.ts | 4 +- .../src/composables/useNavigationTargets.ts | 4 +- .../composables/useSidebarDataOpenRuntime.ts | 4 +- .../sql/editableQueryHiddenKeys.spec.ts | 15 +++++ .../lib/__tests__/table/tableEditing.spec.ts | 25 ++++++++ .../database/databaseTableDataCapabilities.ts | 16 ++++- apps/desktop/src/lib/table/tableEditing.ts | 17 +++++- apps/desktop/src/stores/queryStore.ts | 31 +++++----- crates/dbx-core/src/data_grid_sql.rs | 61 +++++++++++++++---- crates/dbx-core/src/database_export.rs | 6 +- crates/dbx-core/src/sql_dialect.rs | 3 +- .../dbx-core/src/sql_dialect/capabilities.rs | 12 ++++ .../dbx-core/src/sql_dialect/table_select.rs | 13 +++- crates/dbx-core/src/sql_dialect/tests.rs | 54 ++++++++++++++++ 17 files changed, 236 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 70c4b7a561..a01b0bbee1 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -112,7 +112,7 @@ import { isClickHouseExistingRowReadonlyColumn, isHiddenGridColumn, isTdengineExistingRowReadonlyColumn, - usesSyntheticRowIdKey, + shouldIncludeSyntheticRowId, } from "@/lib/table/tableEditing"; import { buildDataGridColumnDistinctValuesSql, buildDataGridConditionalUpdateSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql"; import { @@ -4349,7 +4349,7 @@ async function refreshSavedRows(request: { dirtyRows: ReadonlyMap `(${predicate})`).join(" OR "), limit: requests.size, offset: 0, - includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), + includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), }); if (!visibleLargeValuePreviewActive || generation !== visibleLargeValuePreviewRequestedGeneration || props.result !== sourceResult) return; const connection = connectionStore.getConfig(props.connectionId); @@ -5709,7 +5709,7 @@ async function fetchLargeValueRequestChunk(columnIndex: number, requests: LargeV whereInput: predicates.map((predicate) => `(${predicate})`).join(" OR "), limit: requests.length, offset: 0, - includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), + includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), }); const connection = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined; const results = await api.executeMulti(props.connectionId!, props.executionDatabase ?? props.database ?? "", sql, undefined, uuid(), { @@ -7120,7 +7120,7 @@ async function applyOrderBySearch() { injectDefaultTimeSeriesWhere: true, limit: pageSize.value, whereInput: currentWhereInput(), - includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), + includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), }); markConditionInputsApplied(); await props.onExecuteSql(sql); @@ -7159,7 +7159,7 @@ async function applyWhereFilter() { limit: pageSize.value, injectDefaultTimeSeriesWhere: true, whereInput, - includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), + includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType), }); markConditionInputsApplied(); await props.onExecuteSql(sql); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts index d4c6107f4b..015da4ca08 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridLargeValueReloadSql.spec.ts @@ -18,12 +18,12 @@ describe("DataGrid large-value reload SQL", () => { // Keyless Oracle tables address rows via the hidden __DBX_ROWID alias; the // reload must opt into the ROWIDTOCHAR inline view so the generated SQL // never references __DBX_ROWID as a base-table column (ORA-00904). - expect(fetchChunkSource).toContain("includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); + expect(fetchChunkSource).toContain("includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); }); it("keeps the visible-preview hydration consistent with the synthetic key", () => { const hydrateSource = functionSource("hydrateVisibleLargeValuePreviews", "runVisibleLargeValuePreviewHydration"); - expect(hydrateSource).toContain("includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); + expect(hydrateSource).toContain("includeRowId: shouldIncludeSyntheticRowId(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType)"); }); }); diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index 81a80475b6..595a12a927 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -118,7 +118,10 @@ vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn(), isDebugLoggi // dataTabOpenPolicy 使用真实实现,覆盖设置开关对应的复用范围 vi.mock("@/lib/sidebar/treeNodeContext", () => ({ hasTreeNodeDatabaseContext: () => true })); vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: mocks.buildTableSelectSql })); -vi.mock("@/lib/table/tableEditing", () => ({ usesSyntheticRowIdKey: () => false })); +vi.mock("@/lib/table/tableEditing", () => ({ + usesSyntheticRowIdKey: () => false, + shouldIncludeSyntheticRowId: () => false, +})); vi.mock("@/lib/table/tableOpenPageLimit", () => ({ tableOpenPageLimit: () => 100 })); vi.mock("@/lib/tabs/dataTabActivation", () => ({ canActivateExistingDataTableTab: () => false })); diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 6052674731..64912ec875 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -7,7 +7,7 @@ import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/table import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; import { elasticsearchCursorPageJumpRequestCount } from "@/lib/dataGrid/dataGridPagination"; -import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; +import { shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import * as api from "@/lib/backend/api"; import type { QueryTab } from "@/types/database"; @@ -87,7 +87,7 @@ export function useDataGridActions(activeTab: ComputedRef) const effectiveDbType = effectiveDatabaseTypeForConnection(config); const tableMeta = tableMetaForDataTab(tab); const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : (tableMeta?.primaryKeys ?? []); - const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, tableMeta?.tableType); + const useRowId = shouldIncludeSyntheticRowId(effectiveDbType, primaryKeys, tableMeta?.tableType); // 列投影只信任真实元数据列:tableMetaForDataTab 的 fallback 列来自查询 // 结果(可能是失败结果的 ["Error"]),进入 SQL 会生成非法投影; // 真实列缺失时省略 columns 让 builder 生成 SELECT * diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 9098a01967..e3823f2e8a 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -5,7 +5,7 @@ import { canApplyDataTabMetadata, canReuseActiveMongoTab, type DataTabReuseMode import { isNoSnapshotErrorResult, isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; -import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; +import { editableRowIdentifierColumns, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { uuid } from "@/lib/common/utils"; import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration"; @@ -272,7 +272,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab // 异步窗口内 tab 可能已被复用为其他目标:旧请求的元数据不得落地、 // 不得解除新目标的 pending if (!isCurrentTarget() || !isCurrentGeneration()) return; - const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, targetTableType); + const useRowId = shouldIncludeSyntheticRowId(effectiveDbType, primaryKeys, targetTableType); queryStore.setTableMeta(tabId, { schema: tableSchema, catalog: target.catalog, diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 4763a11993..4b953e1735 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -11,7 +11,7 @@ import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataT import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; -import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; +import { shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; import { canActivateExistingDataTableTab } from "@/lib/tabs/dataTabActivation"; @@ -339,7 +339,7 @@ export function useSidebarDataOpenRuntime() { const loadedTableMeta = cachedTableMeta ?? queryStore.tabs.find((item) => item.id === tabId)?.tableMeta; const columns = loadedTableMeta?.columns ?? []; const primaryKeys = loadedTableMeta?.primaryKeys ?? []; - const includeRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, tableType); + const includeRowId = shouldIncludeSyntheticRowId(effectiveDbType, primaryKeys, tableType); const sql = await buildTableSelectSql({ databaseType: effectiveDbType, driverProfile: config?.driver_profile, diff --git a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts index eaf1bfca75..1b0f74eb32 100644 --- a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts @@ -64,6 +64,21 @@ describe("editable query hidden primary keys", () => { }); }); + it("supports Xugu's unqualified ROWID expression for keyless base tables", () => { + expect( + buildQueryWithHiddenPrimaryKeys({ + sql: "SELECT * FROM APP.USERS t WHERE t.ACTIVE = 1", + databaseType: "xugu", + primaryKeys: ["__DBX_ROWID"], + existingResultNames: ["ID", "NAME"], + sourceExpressions: { __DBX_ROWID: "ROWID" }, + }), + ).toEqual({ + sql: 'SELECT *, ROWID AS "__DBX_PK_0" FROM APP.USERS t WHERE t.ACTIVE = 1', + projections: [{ sourceName: "__DBX_ROWID", alias: "__DBX_PK_0" }], + }); + }); + it("preserves an Oracle FOR UPDATE clause when appending a hidden row key", () => { expect( buildQueryWithHiddenPrimaryKeys({ diff --git a/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts b/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts index a85020c495..1461819c1a 100644 --- a/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableEditing.spec.ts @@ -3,6 +3,7 @@ import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN, DBX_TDENGINE_TBNAME_COLUMN, + canInsertTableRows, canDeleteExistingTdengineRows, canEditExistingTableRows, canUseKeylessRowPredicate, @@ -13,6 +14,7 @@ import { isTdengineExistingRowReadonlyColumn, isTableDataEditable, supportsDataGridTransaction, + shouldIncludeSyntheticRowId, usesSyntheticRowIdKey, } from "@/lib/table/tableEditing"; import type { ColumnInfo, IndexInfo } from "@/types/database"; @@ -46,6 +48,29 @@ describe("tableEditing", () => { expect(editablePrimaryKeys("oceanbase-oracle", [column("ID", true), column("NAME")], "TABLE")).toEqual(["ID"]); }); + it("uses Xugu ROWID for ordinary, partitioned, and temporary tables but not views", () => { + const columns = [column("ID"), column("VALUE")]; + expect(editablePrimaryKeys("xugu", columns, "TABLE")).toEqual([DBX_ROWID_COLUMN]); + expect(editablePrimaryKeys("xugu", columns, "PARTITIONED TABLE")).toEqual([DBX_ROWID_COLUMN]); + expect(editablePrimaryKeys("xugu", columns, "TEMPORARY TABLE")).toEqual([DBX_ROWID_COLUMN]); + expect(editablePrimaryKeys("xugu", columns, "VIEW")).toEqual([]); + expect(usesSyntheticRowIdKey("xugu", [DBX_ROWID_COLUMN], "TABLE")).toBe(true); + expect(usesSyntheticRowIdKey("xugu", [DBX_ROWID_COLUMN], "PARTITIONED TABLE")).toBe(true); + expect(usesSyntheticRowIdKey("xugu", [DBX_ROWID_COLUMN], "TEMPORARY TABLE")).toBe(true); + expect(usesSyntheticRowIdKey("xugu", [DBX_ROWID_COLUMN], "VIEW")).toBe(false); + expect(isTableDataEditable("xugu", [DBX_ROWID_COLUMN], "TABLE")).toBe(true); + expect(isTableDataEditable("xugu", [DBX_ROWID_COLUMN], "VIEW")).toBe(false); + expect(canInsertTableRows("xugu")).toBe(true); + }); + + it("includes Xugu ROWID while cold table metadata has no declared primary keys", () => { + expect(shouldIncludeSyntheticRowId("xugu", [], "TABLE")).toBe(true); + expect(shouldIncludeSyntheticRowId("xugu", [], "PARTITIONED TABLE")).toBe(true); + expect(shouldIncludeSyntheticRowId("xugu", [], "TEMPORARY TABLE")).toBe(true); + expect(shouldIncludeSyntheticRowId("xugu", [], "VIEW")).toBe(false); + expect(shouldIncludeSyntheticRowId("oracle", [], "TABLE")).toBe(false); + }); + it("treats view data tabs as readonly", () => { expect(isTableDataEditable("oracle", [DBX_ROWID_COLUMN], "VIEW")).toBe(false); }); diff --git a/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts b/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts index cec8ab597b..860262c6f1 100644 --- a/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseTableDataCapabilities.ts @@ -1,7 +1,7 @@ import type { DatabaseType } from "@/types/database"; import { isSchemaAware, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; -export type SyntheticEditKey = "oracle-rowid" | "neo4j-element-id"; +export type SyntheticEditKey = "oracle-rowid" | "xugu-rowid" | "neo4j-element-id"; export interface TableDataCapability { insert: boolean; @@ -158,6 +158,20 @@ const DATABASE_CAPABILITY_OVERRIDES: Partial 0 && isTdengineStableTableType(tableType)) return [DBX_TDENGINE_TBNAME_COLUMN, ...primaryKeys]; const syntheticKey = getDatabaseCapability(databaseType).syntheticKey; - if (syntheticKey === "oracle-rowid" && primaryKeys.length === 0) return [DBX_ROWID_COLUMN]; + if ((syntheticKey === "oracle-rowid" || 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,12 +93,23 @@ 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" && primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN) || (syntheticKey === "neo4j-element-id" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN)); + 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)); +} + +/** + * Table-data tabs may start before metadata has populated declared primary keys. + * Xugu base/partitioned/temp tables can still be addressed safely with ROWID, + * so request the hidden projection during that cold-cache window as well. + */ +export function shouldIncludeSyntheticRowId(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean { + if (isViewTableType(tableType)) return false; + if (usesSyntheticRowIdKey(databaseType, primaryKeys, tableType)) return true; + return databaseType === "xugu" && primaryKeys.length === 0; } export function isHiddenGridColumn(databaseType: DatabaseType | undefined, column: string, primaryKeys: string[], tableType?: string): boolean { if (databaseType === "neo4j" && column === DBX_NEO4J_ELEMENT_ID_COLUMN) return true; - return usesSyntheticRowIdKey(databaseType, primaryKeys, tableType) && column.toUpperCase() === DBX_ROWID_COLUMN; + return shouldIncludeSyntheticRowId(databaseType, primaryKeys, tableType) && column.toUpperCase() === DBX_ROWID_COLUMN; } export function isTdengineExistingRowReadonlyColumn(databaseType: DatabaseType | undefined, column: string, columns: ColumnInfo[]): boolean { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index e4fc238e8c..024a9af138 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -36,7 +36,7 @@ import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession"; import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable"; import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest"; import { defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; -import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; +import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing"; import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy"; @@ -105,7 +105,7 @@ const groupedDisplayMetadataLimiter = new MetadataTaskLimiter(GROUPED_DISPLAY_ME console.debug("[DBX][metadata-load:grouped-display-limiter]", event); }); const UPPERCASE_FOLDED_METADATA_TYPES = new Set([...ORACLE_LIKE_METADATA_TYPES, "saphana"]); -const HIDDEN_QUERY_KEY_DATABASE_TYPES = new Set(["mysql", "postgres", "sqlserver", "oracle"]); +const HIDDEN_QUERY_KEY_DATABASE_TYPES = new Set(["mysql", "postgres", "sqlserver", "oracle", "xugu"]); const QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR = "Streaming export is unsupported for this query. Simplify it or use a supported driver."; const BACKGROUND_CLIENT_SESSION_SUFFIXES = ["count", "explain", "export"] as const; const CANCEL_QUERY_TIMEOUT_MS = 10_000; @@ -3902,7 +3902,7 @@ export const useQueryStore = defineStore("query", () => { primaryKeys, ...tableDataLargeValuePreviewOptions(effectiveDbType, tableMeta.columns, primaryKeys, limit), includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, - includeRowId: usesSyntheticRowIdKey(effectiveDbType, primaryKeys, tableMeta.tableType), + includeRowId: shouldIncludeSyntheticRowId(effectiveDbType, primaryKeys, tableMeta.tableType), whereInput: tab.whereInput, injectDefaultTimeSeriesWhere: true, orderBy, @@ -4813,7 +4813,7 @@ export const useQueryStore = defineStore("query", () => { return matches.length === 1 && matches[0]?.type === "table"; } - async function resolveOracleRowIdSafety(tab: QueryTab, loaded: LoadedEditableSource): Promise { + async function resolveOracleRowIdSafety(tab: QueryTab, loaded: LoadedEditableSource, databaseType: DatabaseType): Promise { if (oracleRowIdIsSafeForQuery(tab, loaded)) return true; if (loaded.tableMeta.tableType?.trim()) return false; @@ -4830,7 +4830,7 @@ export const useQueryStore = defineStore("query", () => { database: loaded.tableMeta.database ?? tab.database, schema: loaded.tableMeta.schema, tableName: loaded.tableMeta.tableName, - databaseType: "oracle", + databaseType, driverProfile: connection?.driver_profile || connection?.db_type, catalog: loaded.tableMeta.catalog, }, @@ -4858,7 +4858,7 @@ export const useQueryStore = defineStore("query", () => { databaseType, primaryKeys: missingPrimaryKeys, 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, + sourceExpressions: missingPrimaryKeys.includes(DBX_ROWID_COLUMN) && (databaseType === "oracle" || databaseType === "xugu") ? { [DBX_ROWID_COLUMN]: databaseType === "oracle" ? "ROWIDTOCHAR(ROWID)" : "ROWID" } : undefined, }); if (!rewritten) return unchanged; queryExecutionLog("info", "hidden-primary-keys", { @@ -4885,13 +4885,14 @@ export const useQueryStore = defineStore("query", () => { const hasDirectSourceProjection = analysis.columns.some((column) => Boolean(column.sourceName) && (!column.sourceKey || column.sourceKey === source.key)); if (!wholeSourceProjected && !hasDirectSourceProjection) return unchanged; // Whole-source projections already include declared primary keys. Only - // Oracle needs preflight metadata here to add ROWID for a keyless table. - if (databaseType !== "oracle" && wholeSourceProjected) return unchanged; + // Oracle and Xugu need preflight metadata here to add their synthetic + // row key for a keyless base table. + if (databaseType !== "oracle" && databaseType !== "xugu" && wholeSourceProjected) return unchanged; const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, databaseType, executionDatabase); const cached = getCachedTableMetadata(target.request); let loaded = cached ? loadedEditableSourceFromMetadata(target, cached.metadata) : undefined; - if (!cached && databaseType === "oracle") { + if (!cached && (databaseType === "oracle" || databaseType === "xugu")) { // Oracle column discovery can be slow. A star projection over a table // with a declared primary key already returns the complete row identity, // so SQL can start while the full metadata needed for editing loads. @@ -4922,12 +4923,12 @@ export const useQueryStore = defineStore("query", () => { if (loaded.tableMeta.tableType?.toUpperCase().includes("VIEW")) return unchanged; const columnPrimaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name); const primaryKeys = databaseType === "oracle" ? loaded.tableMeta.primaryKeys : editablePrimaryKeys(databaseType, loaded.tableMeta.columns, loaded.tableMeta.tableType); - const syntheticOracleRowId = databaseType === "oracle" && usesSyntheticRowIdKey(databaseType, primaryKeys, loaded.tableMeta.tableType); - // Oracle base tables without a natural identifier 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 (syntheticOracleRowId && !(await resolveOracleRowIdSafety(tab, loaded))) return unchanged; - const declaredPrimaryKeys = databaseType === "oracle" && !syntheticOracleRowId ? primaryKeys : columnPrimaryKeys; + const syntheticRowId = (databaseType === "oracle" || databaseType === "xugu") && usesSyntheticRowIdKey(databaseType, primaryKeys, loaded.tableMeta.tableType); + // Base tables without a natural identifier use the same ROWID identity + // as table-data tabs (Oracle and Xugu). Confirm the object is a base + // table because selecting ROWID from a view can fail with ORA-01445. + if (syntheticRowId && !(await resolveOracleRowIdSafety(tab, loaded, databaseType))) return unchanged; + const declaredPrimaryKeys = databaseType === "oracle" && !syntheticRowId ? primaryKeys : columnPrimaryKeys; return buildHiddenPrimaryKeyPreparation(tab, sql, databaseType, loaded, primaryKeys, declaredPrimaryKeys, traceId, elapsed); } catch (error) { // Metadata enrichment is optional. Query execution must retain its prior diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 61d6b31fc6..1fb7bd64e8 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -24,7 +24,7 @@ use data_grid_tdengine_sql::{ use crate::models::connection::DatabaseType; use crate::sql_dialect::{ firebird_rows_clause, quote_table_identifier, table_pagination_strategy, uses_oracle_row_id, - uses_single_row_insert_statements, TablePaginationStrategy, + uses_single_row_insert_statements, uses_synthetic_row_id, uses_xugu_row_id, TablePaginationStrategy, }; use crate::transfer::{format_ch_array_sql_literal, format_pg_array_sql_literal}; @@ -378,7 +378,7 @@ pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStateme .enumerate() .filter_map(|(index, column)| Some((column.as_deref()?, index))) .filter(|(column, _)| !primary_key_set.contains(&normalize_column_name(column))) - .filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column))) + .filter(|(column, _)| !is_synthetic_row_id(options.database_type, Some(column))) .map(|(column, index)| (column, index, column_info_for(column_info, column))) .collect(); let primary_key_info = @@ -1122,7 +1122,7 @@ fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option Option<&'static str> { } } -fn is_oracle_row_id(database_type: Option, name: Option<&str>) -> bool { - uses_oracle_row_id(database_type) && name.is_some_and(|name| name.eq_ignore_ascii_case(DBX_ROWID_COLUMN)) +fn is_synthetic_row_id(database_type: Option, name: Option<&str>) -> bool { + uses_synthetic_row_id(database_type) && name.is_some_and(|name| name.eq_ignore_ascii_case(DBX_ROWID_COLUMN)) } pub(crate) fn is_neo4j_element_id(database_type: Option, name: Option<&str>) -> bool { @@ -3085,7 +3085,7 @@ pub(crate) fn is_grid_insert_omitted_column( name: Option<&str>, include_computed_columns: bool, ) -> bool { - is_oracle_row_id(database_type, name) + is_synthetic_row_id(database_type, name) || is_postgres_tsvector_column(database_type, column_info) || (!include_computed_columns && is_non_identity_generated_column(column_info)) } @@ -3096,7 +3096,7 @@ fn is_grid_update_omitted_column( name: Option<&str>, primary_key_set: &[String], ) -> bool { - is_oracle_row_id(database_type, name) + is_synthetic_row_id(database_type, name) || is_clickhouse_key_column(database_type, column_info, name, primary_key_set) || is_non_identity_generated_column(column_info) } @@ -3150,7 +3150,7 @@ fn is_null_write_to_not_null_column( let Some(column) = column else { return false; }; - if is_oracle_row_id(database_type, Some(column)) || is_neo4j_element_id(database_type, Some(column)) { + if is_synthetic_row_id(database_type, Some(column)) || is_neo4j_element_id(database_type, Some(column)) { return false; } value.is_null() && not_null_columns.iter().any(|not_null| not_null == &normalize_column_name(column)) @@ -3231,7 +3231,10 @@ fn clickhouse_no_mutable_columns_error() -> String { } fn predicate_ident(database_type: Option, name: &str, identifier_quote: Option<&str>) -> String { - if is_oracle_row_id(database_type, Some(name)) { + if is_synthetic_row_id(database_type, Some(name)) { + if uses_xugu_row_id(database_type) { + return "ROWID".to_string(); + } "ROWIDTOCHAR(ROWID)".to_string() } else { data_grid_identifier(database_type, name, identifier_quote) @@ -8243,6 +8246,42 @@ mod tests { ); } + #[test] + fn prepares_xugu_rowid_updates_deletes_and_inserts_without_writing_synthetic_key() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Xugu), + identifier_quote: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("APP".to_string()), + table_name: "ROWID_TABLE".to_string(), + primary_keys: vec![DBX_ROWID_COLUMN.to_string()], + columns: Some(vec![ + column(DBX_ROWID_COLUMN, "ROWID", false, None), + column("ID", "INTEGER", false, None), + column("VALUE", "VARCHAR(40)", true, None), + ]), + }, + columns: vec![DBX_ROWID_COLUMN.to_string(), "ID".to_string(), "VALUE".to_string()], + source_columns: None, + rows: vec![vec![json!("AA-1"), json!(1), json!("old")], vec![json!("AA-2"), json!(2), json!("remove")]], + dirty_rows: vec![(0, vec![(2, json!("new"))])], + deleted_rows: vec![1], + new_rows: vec![vec![Value::Null, json!(3), json!("inserted")]], + }); + + assert_eq!(result.validation_error, None); + assert_eq!( + result.statements, + vec![ + "UPDATE \"APP\".\"ROWID_TABLE\" SET \"VALUE\" = 'new' WHERE ROWID = 'AA-1';", + "DELETE FROM \"APP\".\"ROWID_TABLE\" WHERE ROWID = 'AA-2';", + "INSERT INTO \"APP\".\"ROWID_TABLE\" (\"ID\", \"VALUE\") VALUES (3, 'inserted');", + ] + ); + } + #[test] fn prepare_data_grid_save_skips_sqlite_autoincrement_pk_validation() { let result = prepare_data_grid_save(DataGridSaveStatementOptions { diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 8483ba76b3..606a5b3efa 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -1252,9 +1252,9 @@ fn export_sql_statement_bytes(database_type: Option, text: &str) - } pub(crate) fn is_internal_export_column(database_type: Option, column: &str) -> bool { - // Oracle-compatible ROWID is injected only to identify editable rows. It - // is not a physical table column and must never propagate into exports. - crate::sql_dialect::uses_oracle_row_id(database_type) + // Synthetic ROWID is injected only to identify editable rows. It is not a + // physical table column and must never propagate into exports. + crate::sql_dialect::uses_synthetic_row_id(database_type) && column.eq_ignore_ascii_case(crate::sql_dialect::DBX_ROWID_COLUMN) } diff --git a/crates/dbx-core/src/sql_dialect.rs b/crates/dbx-core/src/sql_dialect.rs index 3e63c93dc0..589faab5ba 100644 --- a/crates/dbx-core/src/sql_dialect.rs +++ b/crates/dbx-core/src/sql_dialect.rs @@ -19,7 +19,8 @@ mod descriptor_snapshots; pub use capabilities::{ firebird_rows_clause, is_schema_aware, pagination_strategy, table_pagination_strategy, uses_fetch_first, - uses_oracle_row_id, uses_single_row_insert_statements, PaginationContext, TablePaginationStrategy, + uses_oracle_row_id, uses_single_row_insert_statements, uses_synthetic_row_id, uses_xugu_row_id, PaginationContext, + TablePaginationStrategy, }; pub use ddl_profile::{ profile_for, AutoIncSyntax, DdlDialectProfile, IndexTypePlacement, QuoteStyle, RenameColumnSyntax, TriggerTemplate, diff --git a/crates/dbx-core/src/sql_dialect/capabilities.rs b/crates/dbx-core/src/sql_dialect/capabilities.rs index 253aa5e425..d0a26b23b8 100644 --- a/crates/dbx-core/src/sql_dialect/capabilities.rs +++ b/crates/dbx-core/src/sql_dialect/capabilities.rs @@ -80,6 +80,18 @@ pub fn uses_oracle_row_id(database_type: Option) -> bool { matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) } +/// Xugu exposes an unqualified ROWID pseudo-column for base, partitioned and +/// temporary tables. It is intentionally separate from Oracle's ROWIDTOCHAR +/// representation because qualified ROWID and ROWIDTOCHAR are not supported +/// by Xugu. +pub fn uses_xugu_row_id(database_type: Option) -> bool { + database_type == Some(DatabaseType::Xugu) +} + +pub fn uses_synthetic_row_id(database_type: Option) -> bool { + uses_oracle_row_id(database_type) || uses_xugu_row_id(database_type) +} + /// Oracle 系方言不支持 `INSERT ... VALUES (...), (...)` 多行语法, /// 复制为 INSERT 与导出 INSERT 都需按行生成单条语句。 pub fn uses_single_row_insert_statements(database_type: DatabaseType) -> bool { diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index 1248aa033f..332421982c 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -1,7 +1,7 @@ use crate::models::connection::DatabaseType; use super::capabilities::{ - firebird_rows_clause, table_pagination_strategy, uses_oracle_row_id, TablePaginationStrategy, + firebird_rows_clause, table_pagination_strategy, uses_oracle_row_id, uses_xugu_row_id, TablePaginationStrategy, }; use super::identifiers::{ normalize_where_input, qualified_table_name, qualified_table_name_with_catalog, quote_gaussdb_jdbc_identifier, @@ -272,6 +272,8 @@ pub fn build_table_data_select_sql_with_database( let include_oracle_row_id = options.include_row_id && uses_oracle_row_id(database_type) && !is_view_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); let oracle_view_first_page = database_type == Some(DatabaseType::Oracle) && is_view_table_type(options.table_type.as_deref()) && offset == 0; @@ -280,6 +282,15 @@ pub fn build_table_data_select_sql_with_database( format!("ROWIDTOCHAR(t.ROWID) AS \"{DBX_ROWID_COLUMN}\", t.*") } else if let Some(preview_columns) = build_large_value_preview_columns(&options) { preview_columns + } else if include_xugu_row_id { + if options.columns.is_empty() { + format!("ROWID AS \"{DBX_ROWID_COLUMN}\", *") + } else { + format!( + "ROWID AS \"{DBX_ROWID_COLUMN}\", {}", + quoted_table_columns_or_star(database_type, &options.columns) + ) + } } else { build_select_columns( database_type, diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 448dc2821c..fa13ad7427 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -1326,6 +1326,60 @@ fn builds_oracle_and_neo4j_table_data_queries() { }), "SELECT \"__DBX_ROWID\", \"ID\", \"SMC_RESPONSE\" FROM (SELECT ROWIDTOCHAR(t.ROWID) AS \"__DBX_ROWID\", t.* FROM \"APP\".\"DATA_REPORT_SUB_TASK\" t) WHERE ROWNUM <= 100" ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Xugu), + schema: Some("DBXTEST".to_string()), + table_name: "DBX_LOAD_TABLE_006".to_string(), + table_type: Some("PARTITIONED TABLE".to_string()), + primary_keys: vec![DBX_ROWID_COLUMN.to_string()], + columns: Vec::new(), + fallback_order_columns: Vec::new(), + order_by: None, + limit: Some(100), + offset: None, + where_input: None, + include_row_id: true, + ..Default::default() + }), + "SELECT ROWID AS \"__DBX_ROWID\", * FROM \"DBXTEST\".\"DBX_LOAD_TABLE_006\" LIMIT 100;" + ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Xugu), + schema: Some("DBXTEST".to_string()), + table_name: "DBX_LOAD_TABLE_006".to_string(), + table_type: Some("TEMPORARY TABLE".to_string()), + primary_keys: vec![DBX_ROWID_COLUMN.to_string()], + columns: vec!["ID".to_string(), "NAME".to_string()], + fallback_order_columns: Vec::new(), + order_by: None, + limit: Some(25), + offset: Some(10), + where_input: None, + include_row_id: true, + ..Default::default() + }), + "SELECT ROWID AS \"__DBX_ROWID\", \"ID\", \"NAME\" FROM \"DBXTEST\".\"DBX_LOAD_TABLE_006\" LIMIT 25 OFFSET 10;" + ); + assert_eq!( + build_table_data_select_sql(TableDataSelectSqlOptions { + database_type: Some(DatabaseType::Xugu), + schema: Some("DBXTEST".to_string()), + table_name: "DBX_JOIN_VIEW".to_string(), + table_type: Some("VIEW".to_string()), + primary_keys: vec![DBX_ROWID_COLUMN.to_string()], + columns: vec!["ID".to_string(), "NAME".to_string()], + fallback_order_columns: Vec::new(), + order_by: None, + limit: Some(100), + offset: None, + where_input: None, + include_row_id: true, + ..Default::default() + }), + "SELECT * FROM \"DBXTEST\".\"DBX_JOIN_VIEW\" LIMIT 100;" + ); assert_eq!( build_table_data_select_sql(TableDataSelectSqlOptions { database_type: Some(DatabaseType::Oracle),