feat(grid): edit Oracle join query results via ROWID

This commit is contained in:
zipg authored and GitHub committed 2026-09-08 10:32:09 +08:00
1 parent abc32f1c71
commit 772ce36de8
4 files changed
+110 -9

No files matched your search

@@ -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(
+9 -2
View File
@@ -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<string | undefined> | undefined {
export function sourceColumnsForResult(analysis: EditableQueryInfo, resultColumns: string[], sourceKey?: string, databaseType?: DatabaseType, primaryKeys?: readonly string[]): Array<string | undefined> | 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;
});
}
@@ -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([
+16 -6
View File
@@ -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;