feat(mysql): support safe grouped result editing

This commit is contained in:
zipg
2026-08-19 11:03:17 +08:00
committed by GitHub
parent 533c7a9633
commit 21af5cf600
10 changed files with 507 additions and 36 deletions
@@ -361,6 +361,7 @@ interface DataGridProps {
context?: "results" | "table-data";
autoTransposeSingleRow?: boolean;
sourceColumns?: Array<string | undefined>;
readonlyColumnIndexes?: number[];
/**
* Column comments for a multi-source query result (e.g. JOIN), indexed by
* result-column ordinal (projection order). Populated even when the result is
@@ -3414,6 +3415,7 @@ const editor = useDataGridEditor({
database: computed(() => props.executionDatabase ?? props.database),
tableMeta: computed(() => props.tableMeta),
sourceColumns: computed(() => props.sourceColumns),
readonlyColumnIndexes: computed(() => (props.readonlyColumnIndexes ? new Set(props.readonlyColumnIndexes) : undefined)),
canEditExistingRows,
onExecuteSql: computed(() => props.onExecuteSql),
customSaveHandler: computed(() => props.customSaveHandler),
@@ -223,6 +223,12 @@ const emit = defineEmits<{
const { t, locale } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
function groupedQueryReadonlyColumnIndexes(tab: QueryTab): number[] | undefined {
if (!tab.queryAnalysis?.groupByColumns?.length || !tab.querySourceColumns || !tab.tableMeta?.primaryKeys.length) return undefined;
const primaryKeys = new Set(tab.tableMeta.primaryKeys);
return tab.querySourceColumns.flatMap((column, index) => (column && primaryKeys.has(column) ? [index] : []));
}
const settingsStore = useSettingsStore();
const booleanDisplayMode = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode);
const setBooleanDisplayMode = (mode: "checkbox" | "dropdown") => settingsStore.updateEditorSettings({ dataGridBooleanDisplayMode: mode });
@@ -1585,6 +1591,7 @@ defineExpose({
:loading="activeTab.isExecuting"
:editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler"
:source-columns="activeTab.querySourceColumns"
:readonly-column-indexes="groupedQueryReadonlyColumnIndexes(activeTab)"
:result-column-comments="activeTab.resultColumnComments"
:query-display-source-columns="activeTab.queryDisplaySourceColumns"
:custom-save-handler="mongoQueryResultSaveHandler"
@@ -26,7 +26,7 @@ vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({}),
}));
function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerousRowDeletion = true, cacheKey?: string) {
function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerousRowDeletion = true, cacheKey?: string, readonlyColumnIndexes?: number[]) {
let editor: ReturnType<typeof useDataGridEditor>;
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
columns: ["first", "hidden", "last"],
@@ -49,6 +49,7 @@ function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerou
primaryKeys: [],
})),
sourceColumns: computed(() => sourceColumns),
readonlyColumnIndexes: computed(() => (readonlyColumnIndexes ? new Set(readonlyColumnIndexes) : undefined)),
onExecuteSql: computed(() => undefined),
sql: computed(() => undefined),
searchText: ref(""),
@@ -180,6 +181,14 @@ describe("useDataGridEditor appendPastedRowsToNewRow", () => {
expect(editor.hasPendingChanges.value).toBe(true);
});
it("keeps explicitly read-only mapped columns out of editing and paste", () => {
const editor = createEditor(["first", "hidden", "last"], true, undefined, [0]);
expect(editor.canEditColumn(0)).toBe(false);
expect(editor.canEditColumn(2)).toBe(true);
expect(editor.appendPastedRowsToNewRow(-1, [["Ada"]], [0])).toEqual({ ok: false, reason: "readonly-column" });
});
it("fills following blank new rows before adding more rows", () => {
const editor = createEditor();
editor.newRows.value = [
@@ -86,6 +86,7 @@ export interface UseDataGridEditorOptions {
| undefined
>;
sourceColumns?: ComputedRef<Array<string | undefined> | undefined>;
readonlyColumnIndexes?: ComputedRef<ReadonlySet<number> | undefined>;
canEditExistingRows?: ComputedRef<boolean>;
onExecuteSql: ComputedRef<((sql: string) => Promise<void>) | undefined>;
customSaveHandler?: ComputedRef<CustomSaveHandler | undefined>;
@@ -201,6 +202,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
database,
tableMeta,
sourceColumns = computed(() => undefined),
readonlyColumnIndexes = computed(() => undefined),
canEditExistingRows = computed(() => true),
onExecuteSql,
customSaveHandler,
@@ -607,7 +609,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function canEditColumn(columnIndex: number): boolean {
const sources = sourceColumns.value;
return !sources || sources[columnIndex] !== undefined;
return (!sources || sources[columnIndex] !== undefined) && !readonlyColumnIndexes.value?.has(columnIndex);
}
// --- Row data helpers ---
@@ -40,6 +40,9 @@ describe("grouped query display column mapping", () => {
expect(analysis.tableName).toBe("users");
expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "total"]);
expect(analysis.columns[0]).toMatchObject({ sourceName: "department" });
expect(analysis.groupByColumns).toEqual([expect.objectContaining({ sourceName: "department" })]);
expect(analysis.hasHavingClause).toBeUndefined();
expect(analysis.hasWindowClause).toBeUndefined();
// Aggregate expression has no direct source column.
expect(analysis.columns[1]?.sourceName).toBeUndefined();
});
@@ -52,6 +55,22 @@ describe("grouped query display column mapping", () => {
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "total"]);
expect(analysis.hasHavingClause).toBe(true);
});
it("records window functions so mutation safety can reject them", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS row_number, COUNT(*) FROM users GROUP BY id");
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.groupByColumns).toEqual([expect.objectContaining({ sourceName: "id" })]);
expect(analysis.hasWindowClause).toBe(true);
});
it("records a top-level RIGHT JOIN so a nullable root source cannot become editable", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT u.id, COUNT(*) FROM users u RIGHT OUTER JOIN orders o ON o.user_id = u.id GROUP BY u.id");
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.hasRightJoinClause).toBe(true);
});
it("parses multi-source grouped queries with per-source qualifiers", () => {
+45 -9
View File
@@ -25,6 +25,10 @@ export interface EditableQueryInfo {
allowInsert?: boolean;
allowInsertDelete?: boolean;
distinct?: boolean;
groupByColumns?: EditableQueryColumn[];
hasHavingClause?: boolean;
hasWindowClause?: boolean;
hasRightJoinClause?: boolean;
}
export interface EditableQueryColumn {
@@ -204,9 +208,10 @@ export type QueryEditability = { editable: true; analysis: EditableQueryInfo } |
* base-table columns. DBeaver uses result metadata for the same idea; DBX has to
* recover enough source mapping from SQL text before table metadata is loaded.
*
* Aggregated/set/query-derived results remain read-only. Multi-source queries
* may still be editable later if metadata proves that exactly one source table
* has a complete row identifier in the returned columns.
* Aggregated/set/query-derived results are rejected by this syntax-only pass.
* Multi-source queries, and narrowly supported MySQL grouped queries, may
* still become editable later if physical metadata proves that exactly one
* source table has a complete row identifier in the returned columns.
*/
export function analyzeEditableQuery(sql: string): EditableQueryInfo | null {
const result = analyzeEditableQueryEditability(sql);
@@ -283,16 +288,15 @@ export function analyzeEditableQueryEditability(sql: string): QueryEditability {
/**
* Parse a SELECT statement's projection columns and source tables far enough to
* resolve display-only result metadata (result-column → source-column mapping
* by projection ordinal) WITHOUT deciding editability.
* resolve result metadata (result-column → source-column mapping by projection
* ordinal) WITHOUT deciding editability.
*
* GROUP BY / HAVING queries are classified `aggregation` (read-only) by
* `analyzeEditableQueryEditability`, so that function bails out before parsing
* projections or sources. Their directly projected base-table columns are still
* resolvable for column comments, so the display layer uses this function to
* recover that structure. It is consumed ONLY by display metadata enrichment
* and must never feed row mutation logic — editability is decided exclusively
* by `analyzeEditableQueryEditability`.
* resolvable for column comments. The MySQL query store also combines this
* mapping with physical primary-key metadata to enable a narrowly gated editing
* path; this parser result alone is never sufficient to permit mutation.
*
* Returns `null` when the statement cannot be structurally parsed for display:
* CTEs, set operations, subquery/external sources, or non-SELECT statements.
@@ -324,6 +328,12 @@ export function analyzeSelectStructureForDisplay(sql: string): EditableQueryInfo
if (!sources.length) return null;
const source = sources[0]!;
const groupIndex = findTopLevelKeyword(normalized, "GROUP", fromIndex + "FROM".length);
const groupByColumns = parseGroupByColumns(normalized, groupIndex, sources);
const hasHavingClause = findTopLevelKeyword(normalized, "HAVING", fromIndex + "FROM".length) >= 0;
const hasWindowClause = findTopLevelKeyword(normalized, "OVER", "SELECT".length) >= 0 || findTopLevelKeyword(normalized, "WINDOW", fromIndex + "FROM".length) >= 0;
const hasRightJoinClause = hasTopLevelRightJoin(fromBody);
const selectStar = sources.length === 1 && isSelectStar(selectBody, source.alias);
const columns = selectStar ? [] : parseSelectColumns(selectBody, sources);
if (!selectStar && columns.length === 0) return null;
@@ -340,6 +350,10 @@ export function analyzeSelectStructureForDisplay(sql: string): EditableQueryInfo
selectStar,
columns,
...(distinct ? { distinct: true } : {}),
...(groupByColumns !== undefined ? { groupByColumns } : {}),
...(hasHavingClause ? { hasHavingClause: true } : {}),
...(hasWindowClause ? { hasWindowClause: true } : {}),
...(hasRightJoinClause ? { hasRightJoinClause: true } : {}),
};
if (sources.length > 1) {
analysis.sources = sources;
@@ -348,6 +362,28 @@ export function analyzeSelectStructureForDisplay(sql: string): EditableQueryInfo
return analysis;
}
function parseGroupByColumns(sql: string, groupIndex: number, sources: EditableQuerySource[]): EditableQueryColumn[] | undefined {
if (groupIndex < 0) return undefined;
const groupClause = sql.slice(groupIndex).match(/^GROUP\s+BY\b/i);
if (!groupClause) return [];
const bodyStart = groupIndex + groupClause[0].length;
const bodyEnd = firstTopLevelKeywordIndex(sql, ["HAVING", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR", "WINDOW"], bodyStart);
const body = sql.slice(bodyStart, bodyEnd < 0 ? sql.length : bodyEnd).trim();
return body ? parseSelectColumns(body, sources) : [];
}
function hasTopLevelRightJoin(fromBody: string): boolean {
let searchFrom = 0;
while (searchFrom < fromBody.length) {
const rightIndex = findTopLevelKeyword(fromBody, "RIGHT", searchFrom);
if (rightIndex < 0) return false;
const joinTail = fromBody.slice(rightIndex + "RIGHT".length);
if (/^\s+(?:OUTER\s+)?JOIN\b/i.test(joinTail)) return true;
searchFrom = rightIndex + "RIGHT".length;
}
return false;
}
export function queryEditabilityMessageKey(reason: QueryEditabilityReason): string {
return `grid.queryEditUnsupported.${reason}`;
}
@@ -0,0 +1,248 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const executeMulti = vi.fn();
const executeQuery = vi.fn();
const analyzeEditableQueryEditability = vi.fn();
const getColumns = vi.fn();
const listIndexes = vi.fn();
const listObjects = vi.fn();
const getConnectionConfig = vi.fn();
const lookupLocalCompletionTables = vi.fn();
const buildSortedQuerySql = vi.fn();
const buildDataGridCountSql = vi.fn();
const prepareQueryPaginationExecutionPlan = vi.fn(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
const editorSettings = {
pageSize: 100,
autoCalculateTotalRows: false,
};
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability,
buildDataGridCountSql,
buildSortedQuerySql,
closeClientConnectionSession: vi.fn().mockResolvedValue(undefined),
closeQuerySession: vi.fn().mockResolvedValue(undefined),
executeMulti,
executeQuery,
getColumns,
listIndexes,
listObjects,
prepareQueryPaginationExecutionPlan,
saveOpenTabsState: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: getConnectionConfig,
lookupLocalCompletionTables,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings }),
}));
function column(name: string, isPrimaryKey = false, comment: string | null = null, extra: string | null = null) {
return { name, data_type: "varchar", is_nullable: !isPrimaryKey, column_default: null, is_primary_key: isPrimaryKey, extra, comment };
}
const usersColumns = [column("id", true, "user id"), column("department", false, "department"), column("display_label", false, "display label", "VIRTUAL GENERATED"), column("stored_label", false, "stored label", "STORED GENERATED")];
const ordersColumns = [column("id", true, "order id"), column("user_id"), column("amount")];
const membershipColumns = [column("tenant_id", true), column("user_id", true), column("label")];
function columnsFor(table: string) {
if (table === "orders") return ordersColumns;
if (table === "memberships") return membershipColumns;
return usersColumns;
}
describe("queryStore MySQL grouped-result editing", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { clearTableMetadataCache } = await import("@/lib/metadata/tableMetadataCache");
clearTableMetadataCache();
setActivePinia(createPinia());
getConnectionConfig.mockReturnValue({ id: "mysql-1", name: "MySQL", db_type: "mysql", database: "app", query_timeout_secs: 30 });
getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => columnsFor(table));
listIndexes.mockResolvedValue([]);
listObjects.mockResolvedValue([]);
lookupLocalCompletionTables.mockReturnValue([]);
buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT 1 ORDER BY 1" });
buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*)");
executeQuery.mockResolvedValue({ columns: ["row_count"], rows: [[0]], affected_rows: 0, execution_time_ms: 1 });
analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" });
});
async function executeGrouped(sql: string, columns: string[]) {
executeMulti.mockResolvedValue([{ columns, rows: [columns.map(() => 1)], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeTabSql(tabId, sql);
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.queryEditabilityReason !== undefined || tab.queryAnalysis !== undefined).toBe(true));
return tab;
}
it("enables only direct columns from the single source with a complete primary key", async () => {
const tab = await executeGrouped("SELECT u.id AS user_id, u.department AS dept, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id", ["user_id", "dept", "order_count"]);
expect(tab.queryEditabilityReason).toBeUndefined();
expect(tab.queryAnalysis).toMatchObject({ tableName: "users", editableSourceKey: "u:0", allowInsert: false, allowInsertDelete: false, multiSource: true });
expect(tab.querySourceColumns).toEqual(["id", "department", undefined]);
expect(tab.tableMeta).toMatchObject({ tableName: "users", primaryKeys: ["id"] });
expect(tab.resultColumnComments).toEqual(["user id", "department", undefined]);
expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "id" }, { sourceKey: "u:0", sourceColumn: "department" }, undefined]);
expect(listIndexes).not.toHaveBeenCalled();
});
it("identifies the FROM root independently of metadata completion order", async () => {
getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => {
if (table === "users") await new Promise((resolve) => setTimeout(resolve, 10));
return columnsFor(table);
});
const tab = await executeGrouped("SELECT u.id, u.department, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id", ["id", "department", "order_count"]);
expect(tab.queryEditabilityReason).toBeUndefined();
expect(tab.queryAnalysis).toMatchObject({ tableName: "users", editableSourceKey: "u:0" });
expect(tab.querySourceColumns).toEqual(["id", "department", undefined]);
});
it("keeps direct columns from non-target sources and aggregate expressions read-only", async () => {
const tab = await executeGrouped("SELECT u.id AS user_id, u.department AS dept, o.user_id AS joined_user_id, COUNT(*) AS row_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id", ["user_id", "dept", "joined_user_id", "row_count"]);
expect(tab.queryEditabilityReason).toBeUndefined();
expect(tab.querySourceColumns).toEqual(["id", "department", undefined, undefined]);
expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "id" }, { sourceKey: "u:0", sourceColumn: "department" }, { sourceKey: "o:1", sourceColumn: "user_id" }, undefined]);
});
it("keeps a non-root source read-only even when its complete primary key is grouped", async () => {
const tab = await executeGrouped("SELECT o.id AS order_key, o.department AS dept, e.user_id AS joined_user_id, COUNT(e.id) AS event_count FROM orders e LEFT JOIN users o ON o.id = e.user_id GROUP BY o.id", ["order_key", "dept", "joined_user_id", "event_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
expect(tab.tableMeta).toBeUndefined();
});
it("keeps the result read-only when a composite primary key is incomplete", async () => {
const tab = await executeGrouped("SELECT m.tenant_id, m.label, COUNT(*) AS row_count FROM memberships m GROUP BY m.tenant_id, m.label", ["tenant_id", "label", "row_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
expect(tab.tableMeta).toBeUndefined();
});
it("keeps the result read-only when two source tables are independently writable", async () => {
const tab = await executeGrouped("SELECT u.id AS user_id, u.department, o.id AS order_id, o.amount, COUNT(*) AS row_count FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.department, o.id, o.amount", ["user_id", "department", "order_id", "amount", "row_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
expect(tab.tableMeta).toBeUndefined();
});
it("keeps the result read-only when GROUP BY contains an additional join dimension", async () => {
const tab = await executeGrouped("SELECT u.id, u.department, o.user_id, COUNT(*) AS row_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, o.user_id", ["id", "department", "user_id", "row_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
});
it("keeps the result read-only when GROUP BY contains an additional target column", async () => {
const tab = await executeGrouped("SELECT u.id, u.department, COUNT(*) AS row_count FROM users u GROUP BY u.id, u.department", ["id", "department", "row_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
});
it("does not infer target primary-key grouping through a joined source column", async () => {
const tab = await executeGrouped("SELECT u.id, u.department, COUNT(*) AS row_count FROM users u JOIN orders o ON o.user_id = u.id GROUP BY o.user_id", ["id", "department", "row_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
});
it("keeps DISTINCT, HAVING, windowed, and right-joined grouped results read-only", async () => {
const distinct = await executeGrouped("SELECT DISTINCT u.id, u.department, COUNT(*) AS row_count FROM users u GROUP BY u.id", ["id", "department", "row_count"]);
const having = await executeGrouped("SELECT u.id, u.department, COUNT(*) AS row_count FROM users u GROUP BY u.id HAVING COUNT(*) > 1", ["id", "department", "row_count"]);
const windowed = await executeGrouped("SELECT u.id, u.department, ROW_NUMBER() OVER (ORDER BY u.id) AS row_number, COUNT(*) AS row_count FROM users u GROUP BY u.id", ["id", "department", "row_number", "row_count"]);
const rightJoined = await executeGrouped("SELECT u.id, u.department, COUNT(*) AS row_count FROM users u RIGHT JOIN orders o ON o.user_id = u.id GROUP BY u.id", ["id", "department", "row_count"]);
expect(distinct.queryEditabilityReason).toBe("aggregation");
expect(distinct.queryAnalysis).toBeUndefined();
expect(having.queryEditabilityReason).toBe("aggregation");
expect(having.queryAnalysis).toBeUndefined();
expect(windowed.queryEditabilityReason).toBe("aggregation");
expect(windowed.queryAnalysis).toBeUndefined();
expect(rightJoined.queryEditabilityReason).toBe("aggregation");
expect(rightJoined.queryAnalysis).toBeUndefined();
});
it("keeps generated and expression columns read-only without blocking ordinary target columns", async () => {
const tab = await executeGrouped("SELECT u.id, u.department, u.display_label, u.stored_label, UPPER(u.department) AS upper_department, COUNT(*) AS row_count FROM users u GROUP BY u.id", ["id", "department", "display_label", "stored_label", "upper_department", "row_count"]);
expect(tab.queryEditabilityReason).toBeUndefined();
expect(tab.querySourceColumns).toEqual(["id", "department", undefined, undefined, undefined, undefined]);
expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "id" }, { sourceKey: "u:0", sourceColumn: "department" }, { sourceKey: "u:0", sourceColumn: "display_label" }, { sourceKey: "u:0", sourceColumn: "stored_label" }, undefined, undefined]);
});
it("accepts a complete composite primary key only when it is the exact grouping key", async () => {
const tab = await executeGrouped("SELECT m.tenant_id, m.user_id, m.label, COUNT(*) AS row_count FROM memberships m GROUP BY m.tenant_id, m.user_id", ["tenant_id", "user_id", "label", "row_count"]);
expect(tab.queryEditabilityReason).toBeUndefined();
expect(tab.querySourceColumns).toEqual(["tenant_id", "user_id", "label", undefined]);
expect(tab.tableMeta).toMatchObject({ tableName: "memberships", primaryKeys: ["tenant_id", "user_id"] });
});
it("requires at least one non-primary direct column from the target", async () => {
const tab = await executeGrouped("SELECT u.id, COUNT(*) AS row_count FROM users u GROUP BY u.id", ["id", "row_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
});
it("does not enable grouped editing for non-MySQL connections", async () => {
getConnectionConfig.mockReturnValue({ id: "postgres-1", name: "PostgreSQL", db_type: "postgres", database: "app", query_timeout_secs: 30 });
const tab = await executeGrouped("SELECT u.id AS user_id, u.department AS dept, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id", ["user_id", "dept", "order_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
});
it("does not enable grouped editing for explicit MySQL-compatible driver profiles", async () => {
getConnectionConfig.mockReturnValue({ id: "mariadb-1", name: "MariaDB", db_type: "mysql", driver_profile: "mariadb", database: "app", query_timeout_secs: 30 });
const tab = await executeGrouped("SELECT u.id AS user_id, u.department AS dept, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id", ["user_id", "dept", "order_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
expect(tab.tableMeta).toBeUndefined();
});
it("does not enable grouped editing for an object known to be a view", async () => {
lookupLocalCompletionTables.mockReturnValue([{ name: "users", type: "VIEW" }]);
const tab = await executeGrouped("SELECT u.id, u.department, COUNT(*) AS row_count FROM users u GROUP BY u.id", ["id", "department", "row_count"]);
expect(tab.queryEditabilityReason).toBe("aggregation");
expect(tab.queryAnalysis).toBeUndefined();
expect(tab.querySourceColumns).toBeUndefined();
});
});
+89 -24
View File
@@ -3449,7 +3449,7 @@ export const useQueryStore = defineStore("query", () => {
/**
* Resolve multi-source result columns (by projection ordinal) back to exactly
* one base column per source, then surface the resolved column comments and a
* display-only result->source mapping. Reuses the same database-aware binder
* result->source mapping. Reuses the same database-aware binder
* as the editability analysis, so `name AS username` (uniquely resolvable
* unqualified alias) maps back to its physical column and quoted mixed-case
* identifiers keep exact casing. Ambiguous or unresolved columns yield
@@ -3478,14 +3478,31 @@ export const useQueryStore = defineStore("query", () => {
return { comments, mapping };
}
function mysqlColumnIsGenerated(column: { extra: string | null }): boolean {
const extra = column.extra?.trim().toLowerCase() ?? "";
return extra.includes("virtual generated") || extra.includes("stored generated");
}
function groupedByExactlyOneSourcePrimaryKey(loaded: LoadedEditableSource, groupByRefs: Array<QueryResultSourceColumnRef | undefined>): boolean {
const primaryKeys = loaded.tableMeta.primaryKeys;
if (!primaryKeys.length || groupByRefs.length !== primaryKeys.length) return false;
const groupedColumns = groupByRefs.flatMap((ref) => (ref?.sourceKey === loaded.source.key ? [ref.sourceColumn] : []));
return groupedColumns.length === primaryKeys.length && new Set(groupedColumns).size === primaryKeys.length && primaryKeys.every((primaryKey) => groupedColumns.includes(primaryKey));
}
function canInsertIntoEditableQuerySource(tab: QueryTab, databaseType: DatabaseType | undefined, loaded: LoadedEditableSource, sourceColumns: readonly (string | undefined)[] | undefined): boolean {
if (!canInsertTableRows(databaseType) || !sourceColumns?.length || !sourceColumns.every(Boolean)) return false;
const knownTableType =
const knownTableType = knownEditableQuerySourceTableType(tab, loaded);
return !knownTableType?.toUpperCase().includes("VIEW");
}
function knownEditableQuerySourceTableType(tab: QueryTab, loaded: LoadedEditableSource): string | undefined {
return (
loaded.tableMeta.tableType ??
useConnectionStore()
.lookupLocalCompletionTables(tab.connectionId!, loaded.tableMeta.database ?? tab.database, loaded.tableMeta.tableName, 20, loaded.tableMeta.schema, loaded.tableMeta.catalog)
.find((table) => table.name.toLowerCase() === loaded.tableMeta.tableName.toLowerCase())?.type;
return !knownTableType?.toUpperCase().includes("VIEW");
.find((table) => table.name.toLowerCase() === loaded.tableMeta.tableName.toLowerCase())?.type
);
}
interface EditableQueryExecutionPreparation {
@@ -3587,10 +3604,10 @@ export const useQueryStore = defineStore("query", () => {
tableName: target.request.tableName,
tableType: loadedColumns.tableType,
columns: loadedColumns.columns,
// Display-only enrichment never resolves row identity (indexes), so
// there are no primary keys to carry; resolveMultiSourceColumnInfo only
// consumes `source` + `columns`.
primaryKeys: [],
// MySQL getColumns already marks declared primary-key columns. Keep the
// columns-only path free of index discovery while allowing grouped
// results to prove that one physical row is uniquely identifiable.
primaryKeys: target.request.databaseType === "mysql" && target.request.driverProfile === "mysql" ? loadedColumns.columns.filter((column) => column.is_primary_key).map((column) => column.name) : [],
},
};
}
@@ -3726,15 +3743,12 @@ export const useQueryStore = defineStore("query", () => {
}
/**
* Display-only result-column enrichment for aggregated (GROUP BY / HAVING)
* results: parse the SELECT structure, load every source table's metadata,
* resolve each result column back to exactly one base-table column by
* projection ordinal, and return the resolved comments + display mapping.
* Best-effort by design: returns `undefined` when the statement cannot be
* structurally parsed or source metadata cannot be loaded — the query result
* itself and editability are never affected.
* Resolve grouped result columns by projection ordinal. All databases use the
* mapping for comments. MySQL may additionally edit direct columns from one
* uniquely identifiable base table; aggregate expressions and columns from
* every other source remain read-only.
*/
async function resolveAggregationDisplayColumnInfo(tab: QueryTab, sql: string, executionDatabase: string, traceId: string | undefined): Promise<{ comments: Array<string | undefined>; mapping: Array<QueryResultSourceColumnRef | undefined> } | undefined> {
async function resolveAggregationQueryMetadata(tab: QueryTab, sql: string, executionDatabase: string, traceId: string | undefined): Promise<QueryMetadataPatch | undefined> {
if (tab.mode !== "query" || !tab.connectionId || !tab.result || !tab.result.columns.length) return undefined;
const conn = useConnectionStore().getConfig(tab.connectionId);
const dbType = conn?.db_type || "";
@@ -3763,7 +3777,63 @@ export const useQueryStore = defineStore("query", () => {
}),
),
);
return resolveMultiSourceColumnInfo(dbType, analysis, tab.result.columns, loadedSources);
const displayInfo = resolveMultiSourceColumnInfo(dbType, analysis, tab.result.columns, loadedSources);
const readOnlyPatch: QueryMetadataPatch = {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: "aggregation",
tableMeta: undefined,
resultColumnComments: displayInfo.comments,
queryDisplaySourceColumns: displayInfo.mapping,
};
if (dbType !== "mysql" || (conn?.driver_profile || conn?.db_type) !== "mysql") return readOnlyPatch;
// Mutation safety boundary: the FROM root must remain on the preserved
// side of the join tree, and GROUP BY must resolve to exactly that table's
// declared primary key. This makes every editable result row identify one
// physical root row even when joined rows are collapsed by aggregation.
if (analysis.distinct || analysis.hasHavingClause || analysis.hasWindowClause || analysis.hasRightJoinClause || !analysis.groupByColumns?.length) return readOnlyPatch;
const groupByRefs = resolveSourceColumnsByOrdinal(
dbType,
{ ...analysis, selectStar: false, columns: analysis.groupByColumns },
loadedSources.map((loaded) => ({ source: loaded.source, columns: loaded.tableMeta.columns })),
analysis.groupByColumns.length,
);
const candidates = loadedSources
.map((loaded) => {
const sourceColumns = displayInfo.mapping.map((ref) => {
if (ref?.sourceKey !== loaded.source.key) return undefined;
const column = loaded.tableMeta.columns.find((candidate) => candidate.name === ref.sourceColumn);
return column && !mysqlColumnIsGenerated(column) ? ref.sourceColumn : undefined;
});
const primaryKeySet = new Set(loaded.tableMeta.primaryKeys);
const hasCompletePrimaryKey = loaded.tableMeta.primaryKeys.length > 0 && loaded.tableMeta.primaryKeys.every((primaryKey) => sourceColumns.includes(primaryKey));
const editableSourceColumnCount = sourceColumns.filter((column) => column && !primaryKeySet.has(column)).length;
const hasExactPrimaryKeyGrouping = groupedByExactlyOneSourcePrimaryKey(loaded, groupByRefs);
return { ...loaded, sourceColumns, isRootSource: loaded.source.key === sources[0]!.key, hasCompletePrimaryKey, hasExactPrimaryKeyGrouping, editableSourceColumnCount };
})
.filter((loaded) => loaded.isRootSource && loaded.hasCompletePrimaryKey && loaded.hasExactPrimaryKeyGrouping && loaded.editableSourceColumnCount > 0 && !knownEditableQuerySourceTableType(tab, loaded)?.toUpperCase().includes("VIEW"));
// More than one writable source is ambiguous. Refuse the entire result
// instead of guessing which table an edit should mutate.
if (candidates.length !== 1) return readOnlyPatch;
const target = candidates[0]!;
return {
queryAnalysis: {
...target.analysis,
editableSourceKey: target.source.key,
allowInsert: false,
allowInsertDelete: false,
multiSource: sources.length > 1,
},
querySourceColumns: target.sourceColumns,
queryEditabilityReason: undefined,
tableMeta: target.tableMeta,
resultColumnComments: displayInfo.comments,
queryDisplaySourceColumns: displayInfo.mapping,
};
} catch (err) {
console.error("[DBX] ERROR fetching columns for grouped query metadata:", err);
return undefined;
@@ -3790,18 +3860,13 @@ export const useQueryStore = defineStore("query", () => {
elapsed: elapsed?.(),
});
if (!editability.editable) {
// Grouped (aggregation) results stay read-only, but their directly
// projected base-table columns can still be resolved for display metadata
// (column comments). This enrichment is display-only — it never enables
// row mutation for aggregated results (fixes #6463). Every other
// non-editable reason keeps the previous behavior.
const displayInfo = editability.reason === "aggregation" ? await resolveAggregationDisplayColumnInfo(tab, sql, executionDatabase, traceId) : undefined;
const aggregationPatch = editability.reason === "aggregation" ? await resolveAggregationQueryMetadata(tab, sql, executionDatabase, traceId) : undefined;
if (aggregationPatch) return aggregationPatch;
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
queryEditabilityReason: editability.reason,
tableMeta: undefined,
...(displayInfo ? { resultColumnComments: displayInfo.comments, queryDisplaySourceColumns: displayInfo.mapping } : {}),
};
}
const analysis = editability.analysis;
+9
View File
@@ -1291,6 +1291,15 @@ export interface QueryTab {
resultName: string;
expression: string;
}[];
groupByColumns?: {
sourceName?: string;
sourceNameQuoted?: boolean;
sourceQualifier?: string;
sourceKey?: string;
star?: boolean;
resultName: string;
expression: string;
}[];
};
querySourceColumns?: Array<string | undefined>;
/**
+75 -1
View File
@@ -2835,7 +2835,8 @@ fn is_postgres_tsvector_type(data_type: &str) -> bool {
pub(crate) fn is_non_identity_generated_column(column_info: Option<&DataGridColumnInfo>) -> bool {
let extra = column_info.and_then(|column| column.extra.as_deref()).unwrap_or("").to_ascii_lowercase();
extra.contains("generated always as") && !extra.contains("identity")
(extra.contains("generated always as") || extra.contains("virtual generated") || extra.contains("stored generated"))
&& !extra.contains("identity")
}
fn is_null_write_to_not_null_column(
@@ -4923,6 +4924,79 @@ mod tests {
assert!(result.rollback_statements.is_empty());
}
#[test]
fn mysql_grouped_result_update_uses_physical_columns_and_primary_key() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![column("id", "bigint", false, None), column("department", "varchar", false, None)]),
},
columns: vec!["用户ID".to_string(), "部门".to_string(), "订单数".to_string()],
source_columns: Some(vec![Some("id".to_string()), Some("department".to_string()), None]),
rows: vec![vec![json!(7), json!("sales"), json!(3)]],
dirty_rows: vec![(0, vec![(1, json!("support"))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `app`.`users` SET `department` = 'support' WHERE `id` = 7;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `app`.`users` SET `department` = 'sales' WHERE `id` = 7 AND BINARY `department` = 'support';"]
);
}
#[test]
fn mysql_update_omits_virtual_and_stored_generated_columns() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::Mysql),
identifier_quote: None,
table_meta: DataGridTableMeta {
catalog: None,
database: None,
schema: Some("app".to_string()),
table_name: "users".to_string(),
primary_keys: vec!["id".to_string()],
columns: Some(vec![
column("id", "bigint", false, None),
column("virtual_label", "varchar", true, Some("VIRTUAL GENERATED")),
column("stored_label", "varchar", true, Some("STORED GENERATED")),
column("department", "varchar", false, None),
]),
},
columns: vec![
"id".to_string(),
"virtual_label".to_string(),
"stored_label".to_string(),
"department".to_string(),
],
source_columns: Some(vec![
Some("id".to_string()),
Some("virtual_label".to_string()),
Some("stored_label".to_string()),
Some("department".to_string()),
]),
rows: vec![vec![json!(7), json!("sales-7"), json!("SALES"), json!("sales")]],
dirty_rows: vec![(0, vec![(1, json!("support-7")), (2, json!("SUPPORT")), (3, json!("support"))])],
deleted_rows: vec![],
new_rows: vec![],
});
assert_eq!(result.validation_error, None);
assert_eq!(result.statements, vec!["UPDATE `app`.`users` SET `department` = 'support' WHERE `id` = 7;"]);
assert_eq!(
result.rollback_statements,
vec!["UPDATE `app`.`users` SET `department` = 'sales' WHERE `id` = 7 AND BINARY `department` = 'support';"]
);
}
#[test]
fn kingbase_column_index_prefers_exact_case_and_rejects_ambiguous_fallback() {
let columns = vec![Some("ckg023".to_string()), Some("CKG023".to_string())];