fix(query): enable editing multi-table JOIN result sets
This commit is contained in:
16 files changed
+345
-22
No files matched your search
@@ -461,6 +461,7 @@ interface DataGridProps {
|
||||
pageSizePreference?: DataGridPageSizePreference;
|
||||
autoTransposeSingleRow?: boolean;
|
||||
sourceColumns?: Array<string | undefined>;
|
||||
joinedWriteTargets?: import("@/types/database").QueryTab["queryWriteTargets"];
|
||||
readonlyColumnIndexes?: number[];
|
||||
/**
|
||||
* Column comments for a multi-source query result (e.g. JOIN), indexed by
|
||||
@@ -4383,6 +4384,7 @@ const editor = useDataGridEditor({
|
||||
database: computed(() => props.executionDatabase ?? props.database),
|
||||
tableMeta: computed(() => props.tableMeta),
|
||||
sourceColumns: computed(() => props.sourceColumns),
|
||||
joinedWriteTargets: computed(() => props.joinedWriteTargets),
|
||||
readonlyColumnIndexes: computed(() => (props.readonlyColumnIndexes ? new Set(props.readonlyColumnIndexes) : undefined)),
|
||||
canEditExistingRows,
|
||||
onExecuteSql: computed(() => props.onExecuteSql),
|
||||
|
||||
@@ -1852,6 +1852,7 @@ defineExpose({
|
||||
:loading="activeResultIsLoading"
|
||||
:editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler"
|
||||
:source-columns="activeTab.querySourceColumns"
|
||||
:joined-write-targets="activeTab.queryWriteTargets"
|
||||
:readonly-column-indexes="groupedQueryReadonlyColumnIndexes(activeTab)"
|
||||
:result-column-comments="activeTab.resultColumnComments"
|
||||
:query-display-source-columns="activeTab.queryDisplaySourceColumns"
|
||||
|
||||
@@ -627,6 +627,8 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
|
||||
function createSaveTestEditor(
|
||||
options: {
|
||||
joinedWriteTargets?: import("@/types/database").QueryTab["queryWriteTargets"];
|
||||
queryResult?: { columns: string[]; rows: CellValue[][] };
|
||||
currentPage?: Ref<number>;
|
||||
prepareFullReload?: () => void;
|
||||
customSaveHandler?: { save: ReturnType<typeof vi.fn> };
|
||||
@@ -644,6 +646,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
[2, "pending"],
|
||||
],
|
||||
});
|
||||
if (options.queryResult) result.value = options.queryResult;
|
||||
const editor = useDataGridEditor({
|
||||
result: computed(() => result.value),
|
||||
editable: computed(() => true),
|
||||
@@ -659,6 +662,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
primaryKeys: ["id"],
|
||||
})),
|
||||
sourceColumns: computed(() => undefined),
|
||||
joinedWriteTargets: computed(() => options.joinedWriteTargets),
|
||||
onExecuteSql: computed(() => undefined),
|
||||
customSaveHandler: computed(() => options.customSaveHandler),
|
||||
manualTransactionSessionId: computed(() => options.manualTransactionSessionId),
|
||||
@@ -694,6 +698,55 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
|
||||
});
|
||||
|
||||
it("previews and saves edits to both joined tables in a single transaction", async () => {
|
||||
const refreshSavedRows = vi.fn();
|
||||
const targets = [
|
||||
{ tableMeta: { tableName: "users", primaryKeys: ["id"], columns: [] }, sourceColumns: ["id", "name", undefined, undefined] },
|
||||
{ tableMeta: { tableName: "papers", primaryKeys: ["id"], columns: [] }, sourceColumns: [undefined, undefined, "id", "title"] },
|
||||
];
|
||||
mocks.prepareDataGridSave.mockImplementation(async (options) => ({ statements: ["update " + options.tableMeta.tableName], rollbackStatements: ["undo " + options.tableMeta.tableName] }));
|
||||
mocks.executeInTransaction.mockResolvedValue({ affected_rows: 2 });
|
||||
const { editor, emit } = createSaveTestEditor({ joinedWriteTargets: targets, queryResult: { columns: ["id", "name", "paper_id", "title"], rows: [[1, "old", 20, "old"]] }, refreshSavedRows });
|
||||
editor.dirtyRows.value.set(
|
||||
0,
|
||||
new Map([
|
||||
[1, "new name"],
|
||||
[3, "new title"],
|
||||
]),
|
||||
);
|
||||
expect(await editor.previewChanges()).toEqual(["update users", "update papers"]);
|
||||
expect(mocks.executeInTransaction).not.toHaveBeenCalled();
|
||||
await editor.saveChanges();
|
||||
expect(mocks.executeInTransaction).toHaveBeenCalledWith("connection-1", "app", ["update users", "update papers"], undefined);
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(refreshSavedRows).not.toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith("reload", undefined, "", undefined, undefined, 100, 0);
|
||||
expect(mocks.prepareDataGridSave.mock.calls[0]![0].dirtyRows).toEqual([[0, [[1, "new name"]]]]);
|
||||
expect(mocks.prepareDataGridSave.mock.calls[1]![0].dirtyRows).toEqual([[0, [[3, "new title"]]]]);
|
||||
});
|
||||
|
||||
it("keeps both joined edits pending if the transaction fails", async () => {
|
||||
const targets = [
|
||||
{ tableMeta: { tableName: "users", primaryKeys: ["id"], columns: [] }, sourceColumns: ["id", "name", undefined, undefined] },
|
||||
{ tableMeta: { tableName: "papers", primaryKeys: ["id"], columns: [] }, sourceColumns: [undefined, undefined, "id", "title"] },
|
||||
];
|
||||
mocks.prepareDataGridSave.mockImplementation(async (options) => ({ statements: ["update " + options.tableMeta.tableName], rollbackStatements: [] }));
|
||||
mocks.executeInTransaction.mockRejectedValue(new Error("second update failed"));
|
||||
const { editor, emit } = createSaveTestEditor({ joinedWriteTargets: targets, queryResult: { columns: ["id", "name", "paper_id", "title"], rows: [[1, "old", 20, "old"]] } });
|
||||
editor.dirtyRows.value.set(
|
||||
0,
|
||||
new Map([
|
||||
[1, "new name"],
|
||||
[3, "new title"],
|
||||
]),
|
||||
);
|
||||
await editor.saveChanges();
|
||||
expect(editor.dirtyRows.value.get(0)?.size).toBe(2);
|
||||
expect(editor.saveError.value).toContain("second update failed");
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(emit.mock.calls.some(([event]) => event === "reload")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves query-result edits through the active manual transaction session", async () => {
|
||||
const statement = "UPDATE orders_test SET status='shipped' WHERE id=1";
|
||||
const refreshSavedRows = vi.fn().mockResolvedValue(true);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { joinedSaveOptions } from "@/lib/dataGrid/joinedRowChanges";
|
||||
import { ref, shallowRef, triggerRef, computed, nextTick, watch, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, toRaw, type ComputedRef, type Ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
@@ -99,6 +100,7 @@ export interface UseDataGridEditorOptions {
|
||||
| undefined
|
||||
>;
|
||||
sourceColumns?: ComputedRef<Array<string | undefined> | undefined>;
|
||||
joinedWriteTargets?: ComputedRef<import("@/types/database").QueryTab["queryWriteTargets"]>;
|
||||
readonlyColumnIndexes?: ComputedRef<ReadonlySet<number> | undefined>;
|
||||
canEditExistingRows?: ComputedRef<boolean>;
|
||||
onExecuteSql: ComputedRef<((sql: string) => Promise<void>) | undefined>;
|
||||
@@ -217,6 +219,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
database,
|
||||
tableMeta,
|
||||
sourceColumns = computed(() => undefined),
|
||||
joinedWriteTargets = computed(() => undefined),
|
||||
readonlyColumnIndexes = computed(() => undefined),
|
||||
canEditExistingRows = computed(() => true),
|
||||
onExecuteSql,
|
||||
@@ -666,7 +669,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
}
|
||||
}
|
||||
const columnName = sourceColumns.value?.[columnIndex] ?? result.value.columns[columnIndex];
|
||||
const info = columnName ? tableMeta.value?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase()) : undefined;
|
||||
const sourceTarget = joinedWriteTargets.value?.find((target) => target.sourceColumns[columnIndex] !== undefined);
|
||||
const metadata = sourceTarget?.tableMeta ?? tableMeta.value;
|
||||
const info = columnName ? metadata?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase()) : undefined;
|
||||
if (isBatching && batchColumnInfoCache) {
|
||||
batchColumnInfoCache.set(columnIndex, info);
|
||||
}
|
||||
@@ -1494,12 +1499,33 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareSaveStatements(stmtOptions: NonNullable<ReturnType<typeof saveStatementOptions>>) {
|
||||
const targets = joinedWriteTargets.value;
|
||||
if (!targets?.length) return api.prepareDataGridSave(stmtOptions, saveDriverProfile());
|
||||
const groups = joinedSaveOptions(targets, {
|
||||
...stmtOptions,
|
||||
dirtyRows: new Map(stmtOptions.dirtyRows.map(([index, changes]) => [index, new Map(changes)])),
|
||||
deletedRows: new Set(stmtOptions.deletedRows),
|
||||
});
|
||||
const prepared: Awaited<ReturnType<typeof api.prepareDataGridSave>> = { statements: [], rollbackStatements: [] };
|
||||
for (const group of groups) {
|
||||
const part = await api.prepareDataGridSave(group, saveDriverProfile());
|
||||
if (part.validationError) return part;
|
||||
prepared.statements.push(...part.statements);
|
||||
prepared.rollbackStatements.unshift(...part.rollbackStatements);
|
||||
if (prepared.executionSchema && part.executionSchema && prepared.executionSchema !== part.executionSchema) throw new Error("Joined source tables require incompatible execution schemas.");
|
||||
prepared.executionSchema ??= part.executionSchema;
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function saveDriverProfile() {
|
||||
const id = connectionId.value;
|
||||
return id ? connectionStore.getConfig(id)?.driver_profile : undefined;
|
||||
}
|
||||
|
||||
function tableHistoryTarget() {
|
||||
if (joinedWriteTargets.value?.length) return [...new Set(joinedWriteTargets.value.map(({ tableMeta: target }) => [target.database, target.schema, target.tableName].filter(Boolean).join(".")))].join(", ");
|
||||
if (!tableMeta.value) return "";
|
||||
return [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".");
|
||||
}
|
||||
@@ -1799,7 +1825,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
let preparedSave: Awaited<ReturnType<typeof api.prepareDataGridSave>> | undefined;
|
||||
if (stmtOptions) {
|
||||
try {
|
||||
preparedSave = await api.prepareDataGridSave(stmtOptions, saveDriverProfile());
|
||||
preparedSave = await prepareSaveStatements(stmtOptions);
|
||||
} catch (e: any) {
|
||||
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
|
||||
await finishInterruptedSaveChanges(snapshot);
|
||||
@@ -1892,7 +1918,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
applyDirtyRowsToResult(snapshot);
|
||||
options.onResultPayloadMutated?.();
|
||||
let savedRowsRefreshed = false;
|
||||
if (!manualTransactionSessionId.value && !shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) {
|
||||
if (!joinedWriteTargets.value?.length && !manualTransactionSessionId.value && !shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) {
|
||||
try {
|
||||
savedRowsRefreshed = await options.refreshSavedRows({
|
||||
dirtyRows: snapshot.dirtyRows,
|
||||
@@ -2029,7 +2055,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
}
|
||||
const stmtOptions = saveStatementOptions();
|
||||
if (!stmtOptions) return [];
|
||||
const prepared = await api.prepareDataGridSave(stmtOptions, saveDriverProfile());
|
||||
const prepared = await prepareSaveStatements(stmtOptions);
|
||||
if (prepared?.validationError) {
|
||||
saveError.value = prepared.validationError;
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { analyzeEditableQueryEditability } from "../../sql/sqlAnalysis";
|
||||
it("allows parentheses in JOIN predicates but not derived or function sources", () => {
|
||||
expect(analyzeEditableQueryEditability("SELECT u.id,u.name,p.id,p.title FROM users u JOIN papers p ON (u.id=p.user_id)").editable).toBe(true);
|
||||
for (const sql of ["SELECT u.id,p.id FROM users u JOIN (SELECT id FROM papers) p ON u.id=p.id", "SELECT u.id,p.id FROM users u JOIN generate_series(1,2) p ON u.id=p.id"]) {
|
||||
expect(analyzeEditableQueryEditability(sql).editable).toBe(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { joinedRowChanges, type JoinedWriteSource } from "../../dataGrid/joinedRowChanges";
|
||||
|
||||
const sources: JoinedWriteSource[] = [
|
||||
{ tableKey: "users", primaryKeys: ["id"], sourceColumns: ["id", "name", undefined, undefined] },
|
||||
{ tableKey: "papers", primaryKeys: ["id"], sourceColumns: [undefined, undefined, "id", "title"] },
|
||||
];
|
||||
|
||||
describe("joined row updates", () => {
|
||||
it("routes edits on both tables to their own primary key row", () => {
|
||||
const updates = joinedRowChanges(
|
||||
sources,
|
||||
[[1, "user", 20, "paper"]],
|
||||
new Map([
|
||||
[
|
||||
0,
|
||||
new Map([
|
||||
[1, "renamed"],
|
||||
[3, "retitled"],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(updates).toEqual([
|
||||
{ sourceIndex: 0, rowIndex: 0, changes: new Map([[1, "renamed"]]) },
|
||||
{ sourceIndex: 1, rowIndex: 0, changes: new Map([[3, "retitled"]]) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("coalesces the same user repeated by a one-to-many join", () => {
|
||||
const rows = [
|
||||
[1, "user", 20, "one"],
|
||||
[1, "user", 21, "two"],
|
||||
];
|
||||
const updates = joinedRowChanges(
|
||||
sources,
|
||||
rows,
|
||||
new Map([
|
||||
[0, new Map([[1, "same"]])],
|
||||
[1, new Map([[1, "same"]])],
|
||||
]),
|
||||
);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(() =>
|
||||
joinedRowChanges(
|
||||
sources,
|
||||
rows,
|
||||
new Map([
|
||||
[0, new Map([[1, "first"]])],
|
||||
[1, new Map([[1, "second"]])],
|
||||
]),
|
||||
),
|
||||
).toThrow("Conflicting edits");
|
||||
});
|
||||
|
||||
it("rejects an outer join row without an existing target record", () => {
|
||||
expect(() => joinedRowChanges(sources, [[1, "user", null, null]], new Map([[0, new Map([[3, "new"]])]]))).toThrow("unmatched outer-join");
|
||||
});
|
||||
|
||||
it("never treats an unbound expression or incomplete key as writable", () => {
|
||||
expect(() => joinedRowChanges(sources, [[1, "user", 20, "paper", 5]], new Map([[0, new Map([[4, "value"]])]]))).toThrow("unique source");
|
||||
expect(() => joinedRowChanges([{ ...sources[0]!, primaryKeys: ["missing"] }], [[1, "user"]], new Map([[0, new Map([[1, "new"]])]]))).toThrow("complete primary key");
|
||||
});
|
||||
|
||||
it("distinguishes composite keys and physical tables", () => {
|
||||
const composite = [{ tableKey: "users", primaryKeys: ["tenant", "id"], sourceColumns: ["tenant", "id", "name"] }];
|
||||
expect(
|
||||
joinedRowChanges(
|
||||
composite,
|
||||
[
|
||||
[1, 2, "a"],
|
||||
[2, 2, "b"],
|
||||
],
|
||||
new Map([
|
||||
[0, new Map([[2, "x"]])],
|
||||
[1, new Map([[2, "y"]])],
|
||||
]),
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { CellValue } from "./cellValue";
|
||||
import type { DataGridSaveStatementOptions, DataGridTableMeta } from "./dataGridSql";
|
||||
|
||||
export interface JoinedSaveTarget {
|
||||
tableMeta: DataGridTableMeta;
|
||||
sourceColumns: Array<string | undefined>;
|
||||
}
|
||||
|
||||
/** Feed per-source changes through the existing SQL builder, including undo SQL. */
|
||||
export function joinedSaveOptions(
|
||||
targets: JoinedSaveTarget[],
|
||||
options: Pick<DataGridSaveStatementOptions, "databaseType" | "identifierQuote" | "columns"> & {
|
||||
rows: CellValue[][];
|
||||
dirtyRows: Map<number, Map<number, CellValue>>;
|
||||
newRows: CellValue[][];
|
||||
deletedRows: Set<number>;
|
||||
},
|
||||
): DataGridSaveStatementOptions[] {
|
||||
if (options.newRows.length || options.deletedRows.size) throw new Error("Joined results support updates only.");
|
||||
const sources = targets.map(({ tableMeta, sourceColumns }) => ({
|
||||
tableKey: JSON.stringify([tableMeta.catalog, tableMeta.database, tableMeta.schema, tableMeta.tableName]),
|
||||
primaryKeys: tableMeta.primaryKeys,
|
||||
sourceColumns,
|
||||
}));
|
||||
return joinedRowChanges(sources, options.rows, options.dirtyRows).map((update) => ({
|
||||
databaseType: options.databaseType,
|
||||
identifierQuote: options.identifierQuote,
|
||||
columns: options.columns,
|
||||
tableMeta: targets[update.sourceIndex]!.tableMeta,
|
||||
sourceColumns: targets[update.sourceIndex]!.sourceColumns,
|
||||
rows: options.rows,
|
||||
dirtyRows: [[update.rowIndex, [...update.changes]]],
|
||||
newRows: [],
|
||||
deletedRows: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export interface JoinedWriteSource {
|
||||
/** Physical table identity; aliases of the same table must share this key. */
|
||||
tableKey: string;
|
||||
primaryKeys: string[];
|
||||
sourceColumns: Array<string | undefined>;
|
||||
}
|
||||
|
||||
export interface JoinedRowChange {
|
||||
sourceIndex: number;
|
||||
rowIndex: number;
|
||||
changes: Map<number, CellValue>;
|
||||
}
|
||||
|
||||
/** Route each edited cell to its physical row, coalescing repeated JOIN rows. */
|
||||
export function joinedRowChanges(sources: JoinedWriteSource[], rows: CellValue[][], dirtyRows: Map<number, Map<number, CellValue>>): JoinedRowChange[] {
|
||||
const updates = new Map<string, JoinedRowChange>();
|
||||
for (const [rowIndex, changes] of dirtyRows) {
|
||||
const row = rows[rowIndex];
|
||||
if (!row) throw new Error("The edited query row is no longer available.");
|
||||
for (const [columnIndex, value] of changes) {
|
||||
const owners = sources.flatMap((source, index) => (source.sourceColumns[columnIndex] === undefined ? [] : [index]));
|
||||
if (owners.length !== 1) throw new Error("The edited column does not have a unique source table.");
|
||||
const sourceIndex = owners[0]!;
|
||||
const source = sources[sourceIndex]!;
|
||||
if (!source.primaryKeys.length) throw new Error("A joined row requires a complete primary key.");
|
||||
const keys = source.primaryKeys.map((key) => {
|
||||
const positions = source.sourceColumns.flatMap((name, index) => (name === key ? [index] : []));
|
||||
if (!positions.length) throw new Error("A joined row requires a complete primary key.");
|
||||
const original = row[positions[0]!];
|
||||
if (original === null || original === undefined) throw new Error("Cannot update an unmatched outer-join row.");
|
||||
if (positions.some((position) => JSON.stringify(row[position]) !== JSON.stringify(original))) throw new Error("Conflicting primary key projections in the query result.");
|
||||
return original;
|
||||
});
|
||||
const identity = JSON.stringify([source.tableKey, keys]);
|
||||
let update = updates.get(identity);
|
||||
if (!update) {
|
||||
update = { sourceIndex, rowIndex, changes: new Map() };
|
||||
updates.set(identity, update);
|
||||
}
|
||||
// A self-join can project the same record through different aliases.
|
||||
const target = sources[update.sourceIndex]!;
|
||||
const columnName = source.sourceColumns[columnIndex]!;
|
||||
const targetIndex = target.sourceColumns.indexOf(columnName);
|
||||
if (targetIndex < 0) throw new Error("Repeated source rows have incompatible column projections.");
|
||||
if (update.changes.has(targetIndex) && JSON.stringify(update.changes.get(targetIndex)) !== JSON.stringify(value)) {
|
||||
throw new Error("Conflicting edits refer to the same source row and column.");
|
||||
}
|
||||
update.changes.set(targetIndex, value);
|
||||
}
|
||||
}
|
||||
return [...updates.values()];
|
||||
}
|
||||
@@ -613,7 +613,7 @@ function isSelectStar(body: string, alias: string | undefined): boolean {
|
||||
}
|
||||
|
||||
function parseFromSources(body: string): EditableQuerySource[] {
|
||||
if (!body || /[()]/.test(body)) return [];
|
||||
if (!body) return [];
|
||||
const sources: EditableQuerySource[] = [];
|
||||
let pos = 0;
|
||||
const first = parseTableSourceAt(body, pos, sources.length);
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface TabResultSnapshot {
|
||||
activeResultRunId?: string;
|
||||
queryAnalysis?: QueryTab["queryAnalysis"];
|
||||
querySourceColumns?: QueryTab["querySourceColumns"];
|
||||
queryWriteTargets?: QueryTab["queryWriteTargets"];
|
||||
resultColumnComments?: QueryTab["resultColumnComments"];
|
||||
queryDisplaySourceColumns?: QueryTab["queryDisplaySourceColumns"];
|
||||
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
|
||||
@@ -748,6 +749,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
|
||||
activeResultRunId: tab.activeResultRunId,
|
||||
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
|
||||
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
|
||||
queryWriteTargets: tab.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),
|
||||
resultColumnComments: tab.resultColumnComments ? clonePlain(tab.resultColumnComments) : undefined,
|
||||
queryDisplaySourceColumns: tab.queryDisplaySourceColumns ? [...tab.queryDisplaySourceColumns] : undefined,
|
||||
queryEditabilityReason: tab.queryEditabilityReason,
|
||||
|
||||
+9
-4
@@ -128,10 +128,15 @@ describe("queryStore multi-source result column comments", () => {
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined());
|
||||
|
||||
// Multi-source results stay non-editable: no single tableMeta.
|
||||
expect(tab.tableMeta).toBeUndefined();
|
||||
expect(tab.queryEditabilityReason).toBe("complex-source");
|
||||
expect(tab.querySourceColumns).toBeUndefined();
|
||||
// Both sources return their primary key, so each has its own write target.
|
||||
expect(tab.queryEditabilityReason).toBeUndefined();
|
||||
expect(tab.queryAnalysis).toMatchObject({ multiSource: true, allowInsert: false, allowDelete: false });
|
||||
expect(tab.queryWriteTargets?.map((target) => target.tableMeta.tableName)).toEqual(["orders", "users"]);
|
||||
expect(tab.queryWriteTargets?.map((target) => target.tableMeta.primaryKeys)).toEqual([["id"], ["id"]]);
|
||||
expect(tab.queryWriteTargets?.map((target) => target.sourceColumns)).toEqual([
|
||||
["id", "user_id", undefined, undefined],
|
||||
[undefined, undefined, "id", "name"],
|
||||
]);
|
||||
|
||||
// Comments are indexed by result ordinal, so the second `id` (users.id)
|
||||
// keeps its own comment instead of first-source-wins on the name.
|
||||
|
||||
@@ -1462,6 +1462,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.resultEstimatedBytes = undefined;
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryWriteTargets = undefined;
|
||||
tab.resultColumnComments = undefined;
|
||||
tab.queryDisplaySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = undefined;
|
||||
@@ -1534,6 +1535,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
run.resultEstimatedBytes = undefined;
|
||||
run.queryAnalysis = undefined;
|
||||
run.querySourceColumns = undefined;
|
||||
run.queryWriteTargets = undefined;
|
||||
run.resultColumnComments = undefined;
|
||||
run.queryDisplaySourceColumns = undefined;
|
||||
run.queryEditabilityReason = undefined;
|
||||
@@ -1578,6 +1580,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.resultEvicted = run.resultEvicted;
|
||||
tab.queryAnalysis = run.queryAnalysis;
|
||||
tab.querySourceColumns = run.querySourceColumns;
|
||||
tab.queryWriteTargets = run.queryWriteTargets;
|
||||
tab.resultColumnComments = run.resultColumnComments;
|
||||
tab.queryDisplaySourceColumns = run.queryDisplaySourceColumns;
|
||||
tab.queryEditabilityReason = run.queryEditabilityReason;
|
||||
@@ -1818,6 +1821,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
activeResultRunId: run.id,
|
||||
queryAnalysis: run.queryAnalysis,
|
||||
querySourceColumns: run.querySourceColumns,
|
||||
queryWriteTargets: run.queryWriteTargets,
|
||||
resultColumnComments: run.resultColumnComments,
|
||||
queryDisplaySourceColumns: run.queryDisplaySourceColumns,
|
||||
queryEditabilityReason: run.queryEditabilityReason,
|
||||
@@ -1899,6 +1903,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
resultEvicted: tab.resultEvicted,
|
||||
queryAnalysis: tab.queryAnalysis,
|
||||
querySourceColumns: tab.querySourceColumns,
|
||||
queryWriteTargets: tab.queryWriteTargets,
|
||||
resultColumnComments: tab.resultColumnComments,
|
||||
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
|
||||
queryEditabilityReason: tab.queryEditabilityReason,
|
||||
@@ -1999,6 +2004,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
resultEvicted: tab.resultEvicted,
|
||||
queryAnalysis: tab.queryAnalysis,
|
||||
querySourceColumns: tab.querySourceColumns,
|
||||
queryWriteTargets: tab.queryWriteTargets,
|
||||
resultColumnComments: tab.resultColumnComments,
|
||||
queryDisplaySourceColumns: tab.queryDisplaySourceColumns,
|
||||
queryEditabilityReason: tab.queryEditabilityReason,
|
||||
@@ -3699,6 +3705,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined,
|
||||
queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined,
|
||||
querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined,
|
||||
queryWriteTargets: original.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),
|
||||
resultColumnComments: original.resultColumnComments ? [...original.resultColumnComments] : undefined,
|
||||
queryDisplaySourceColumns: original.queryDisplaySourceColumns ? [...original.queryDisplaySourceColumns] : undefined,
|
||||
queryEditabilityReason: original.queryEditabilityReason,
|
||||
@@ -4569,7 +4576,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return producedResult;
|
||||
}
|
||||
|
||||
type QueryMetadataPatch = Pick<QueryTab, "queryAnalysis" | "querySourceColumns" | "queryEditabilityReason" | "tableMeta" | "resultColumnComments" | "queryDisplaySourceColumns">;
|
||||
type QueryMetadataPatch = Pick<QueryTab, "queryAnalysis" | "querySourceColumns" | "queryWriteTargets" | "queryEditabilityReason" | "tableMeta" | "resultColumnComments" | "queryDisplaySourceColumns">;
|
||||
|
||||
type LoadedEditableSource = {
|
||||
source: EditableQuerySource;
|
||||
@@ -4658,6 +4665,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch) {
|
||||
tab.queryAnalysis = patch.queryAnalysis;
|
||||
tab.querySourceColumns = patch.querySourceColumns;
|
||||
tab.queryWriteTargets = patch.queryWriteTargets;
|
||||
tab.queryEditabilityReason = patch.queryEditabilityReason;
|
||||
tab.mongoEditTarget = undefined;
|
||||
tab.tableMeta = patch.tableMeta;
|
||||
@@ -5173,11 +5181,16 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
if (candidates.length > 1) {
|
||||
const target = candidates[0]!;
|
||||
return {
|
||||
queryAnalysis: undefined,
|
||||
querySourceColumns: undefined,
|
||||
queryEditabilityReason: "complex-source",
|
||||
tableMeta: undefined,
|
||||
queryAnalysis: { ...target.analysis, multiSource: true, allowInsert: false, allowDelete: false, allowInsertDelete: false },
|
||||
querySourceColumns: tab.result.columns.map((_, index) => {
|
||||
const owners = candidates.filter((candidate) => candidate.sourceColumns?.[index] !== undefined);
|
||||
return owners.length === 1 ? owners[0]!.sourceColumns![index] : undefined;
|
||||
}),
|
||||
queryWriteTargets: candidates.map((candidate) => ({ tableMeta: candidate.tableMeta, sourceColumns: candidate.sourceColumns! })),
|
||||
queryEditabilityReason: undefined,
|
||||
tableMeta: target.tableMeta,
|
||||
resultColumnComments: multiSourceInfo?.comments,
|
||||
queryDisplaySourceColumns: multiSourceInfo?.mapping,
|
||||
};
|
||||
@@ -5614,6 +5627,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
touchResult(current);
|
||||
current.queryAnalysis = undefined;
|
||||
current.querySourceColumns = undefined;
|
||||
current.queryWriteTargets = undefined;
|
||||
current.resultColumnComments = undefined;
|
||||
current.queryDisplaySourceColumns = undefined;
|
||||
current.queryEditabilityReason = undefined;
|
||||
@@ -5998,6 +6012,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
touchResult(current);
|
||||
current.queryAnalysis = undefined;
|
||||
current.querySourceColumns = undefined;
|
||||
current.queryWriteTargets = undefined;
|
||||
current.resultColumnComments = undefined;
|
||||
current.queryDisplaySourceColumns = undefined;
|
||||
current.queryEditabilityReason = undefined;
|
||||
@@ -6065,6 +6080,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
touchResult(current);
|
||||
current.queryAnalysis = undefined;
|
||||
current.querySourceColumns = undefined;
|
||||
current.queryWriteTargets = undefined;
|
||||
current.resultColumnComments = undefined;
|
||||
current.queryDisplaySourceColumns = undefined;
|
||||
current.queryEditabilityReason = undefined;
|
||||
@@ -6559,6 +6575,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
current.queryAnalysis = undefined;
|
||||
current.querySourceColumns = undefined;
|
||||
current.queryWriteTargets = undefined;
|
||||
current.resultColumnComments = undefined;
|
||||
current.queryDisplaySourceColumns = undefined;
|
||||
current.queryEditabilityReason = undefined;
|
||||
@@ -7076,6 +7093,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
touchResult(tab, Date.now(), { reuseEstimatedBytes: true });
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
tab.queryWriteTargets = undefined;
|
||||
tab.resultColumnComments = undefined;
|
||||
tab.queryDisplaySourceColumns = undefined;
|
||||
tab.queryEditabilityReason = undefined;
|
||||
@@ -7202,6 +7220,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
|
||||
tab.queryAnalysis = snapshot.queryAnalysis;
|
||||
tab.querySourceColumns = snapshot.querySourceColumns;
|
||||
tab.queryWriteTargets = snapshot.queryWriteTargets;
|
||||
tab.resultColumnComments = snapshot.resultColumnComments;
|
||||
tab.queryDisplaySourceColumns = snapshot.queryDisplaySourceColumns;
|
||||
tab.queryEditabilityReason = snapshot.queryEditabilityReason;
|
||||
|
||||
@@ -831,6 +831,7 @@ export interface QueryResultRun {
|
||||
resultEvicted?: boolean;
|
||||
queryAnalysis?: QueryTab["queryAnalysis"];
|
||||
querySourceColumns?: QueryTab["querySourceColumns"];
|
||||
queryWriteTargets?: QueryTab["queryWriteTargets"];
|
||||
resultColumnComments?: QueryTab["resultColumnComments"];
|
||||
queryDisplaySourceColumns?: QueryTab["queryDisplaySourceColumns"];
|
||||
queryEditabilityReason?: QueryTab["queryEditabilityReason"];
|
||||
@@ -1370,6 +1371,7 @@ export interface QueryTab {
|
||||
}[];
|
||||
};
|
||||
querySourceColumns?: Array<string | undefined>;
|
||||
queryWriteTargets?: Array<{ tableMeta: NonNullable<QueryTab["tableMeta"]>; sourceColumns: Array<string | undefined> }>;
|
||||
/**
|
||||
* Column comments for a multi-source query result (e.g. JOIN), indexed by
|
||||
* result-column ordinal (projection order). Each entry is the comment of the
|
||||
|
||||
Reference in new issue
Block a user