feat(grid): edit Oracle join query results via ROWID
This commit is contained in:
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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user