mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(mysql): support safe grouped result editing
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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())];
|
||||
|
||||
Reference in New Issue
Block a user