fix(grid): keep computed query columns individually read-only

Closes #6132
This commit is contained in:
t8y2 committed 2026-08-13 09:28:31 +00:00
1 parent 7a21e7e5f9
commit 37f64dbdf4
4 files changed
+383 -23

No files matched your search

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes } from "@/lib/sql/editableQueryHiddenKeys";
import { analyzeEditableQueryEditability } from "@/lib/sql/sqlAnalysis";
import { analyzeEditableQueryEditability, sourceColumnsForResult } from "@/lib/sql/sqlAnalysis";
describe("editable query hidden primary keys", () => {
it("appends quoted MySQL primary keys without changing the visible projection", () => {
@@ -157,6 +157,50 @@ describe("editable query hidden primary keys", () => {
}
});
it("maps MySQL JSON expressions with implicit aliases without making them writeable", () => {
const result = analyzeEditableQueryEditability("select id, status, extra->>'$.mode' mode, extra->>'$.template' tmpl from jobs");
expect(result.editable).toBe(true);
if (!result.editable) return;
expect(result.analysis.columns).toEqual([
expect.objectContaining({ sourceName: "id", resultName: "id", expression: "id" }),
expect.objectContaining({ sourceName: "status", resultName: "status", expression: "status" }),
expect.objectContaining({ sourceName: undefined, resultName: "mode", expression: "extra->>'$.mode'" }),
expect.objectContaining({ sourceName: undefined, resultName: "tmpl", expression: "extra->>'$.template'" }),
]);
expect(sourceColumnsForResult(result.analysis, ["id", "status", "mode", "tmpl"])).toEqual(["id", "status", undefined, undefined]);
});
it("uses MySQL expression text as the result label when no alias is present", () => {
const result = analyzeEditableQueryEditability("select id, status, extra->>'$.mode', extra->>'$.template' from jobs");
expect(result.editable).toBe(true);
if (!result.editable) return;
expect(sourceColumnsForResult(result.analysis, ["id", "status", "extra->>'$.mode'", "extra->>'$.template'"])).toEqual(["id", "status", undefined, undefined]);
});
it("preserves direct columns and explicit aliases while keeping computed projections read-only", () => {
const result = analyzeEditableQueryEditability("select id, status as state, lower(name) normalized, count_value + 1 from jobs");
expect(result.editable).toBe(true);
if (!result.editable) return;
expect(sourceColumnsForResult(result.analysis, ["id", "state", "normalized", "count_value + 1"])).toEqual(["id", "status", undefined, undefined]);
});
it("does not mistake the right operand of an unaliased expression for an alias", () => {
const result = analyzeEditableQueryEditability("select id, id + status, lower(status) from jobs");
expect(result.editable).toBe(true);
if (!result.editable) return;
expect(sourceColumnsForResult(result.analysis, ["id", "id + status", "lower(status)"])).toEqual(["id", undefined, undefined]);
});
it("keeps aggregate, join-star, and set-operation boundaries read-only", () => {
expect(analyzeEditableQueryEditability("select id, count(*) total from jobs group by id")).toEqual({ editable: false, reason: "aggregation" });
expect(analyzeEditableQueryEditability("select * from jobs j join users u on u.id = j.user_id")).toEqual({ editable: false, reason: "complex-source" });
expect(analyzeEditableQueryEditability("select id from jobs union select id from archived_jobs")).toEqual({ editable: false, reason: "set-operation" });
});
it("resolves appended aliases to result indexes", () => {
expect(
hiddenResultColumnIndexes(
+79 -9
View File
@@ -261,7 +261,8 @@ function parseStarSelectColumn(col: string, sources?: EditableQuerySource[]): Ed
}
function parseComputedSelectColumn(col: string): EditableQueryColumn | null {
const alias = parseExpressionAlias(col);
const expression = col.trim();
const alias = parseExpressionAlias(col) ?? (looksLikeComputedExpression(expression) ? { expression, resultName: expression } : null);
if (!alias) return null;
return {
sourceName: undefined,
@@ -273,14 +274,83 @@ function parseComputedSelectColumn(col: string): EditableQueryColumn | null {
function parseExpressionAlias(col: string): { expression: string; resultName: string } | null {
const asMatch = col.match(/\bAS\s+((?:"[^"]+")|(?:`[^`]+`)|(?:\[[^\]]+\])|(?:'(?:''|[^'])*')|(?:[\p{ID_Start}_][\p{ID_Continue}$]*))\s*$/iu);
const implicitStringMatch = asMatch ? undefined : col.match(/\s+('(?:''|[^'])*')\s*$/u);
const aliasText = asMatch?.[1] ?? implicitStringMatch?.[1];
const expressionEnd = asMatch?.index ?? implicitStringMatch?.index;
if (!aliasText || expressionEnd === undefined) return null;
const alias = readSelectAlias(aliasText);
if (!alias || alias.end !== aliasText.length) return null;
const expression = col.slice(0, expressionEnd).trim();
return expression ? { expression, resultName: alias.value } : null;
if (asMatch?.index !== undefined) {
const alias = readSelectAlias(asMatch[1]!);
const expression = col.slice(0, asMatch.index).trim();
if (alias && alias.end === asMatch[1]!.length && expression) return { expression, resultName: alias.value };
}
const trimmed = col.trimEnd();
const index = lastTopLevelWhitespaceStart(trimmed);
if (index !== undefined) {
const aliasText = trimmed.slice(index).trim();
const alias = readSelectAlias(aliasText);
const expression = trimmed.slice(0, index).trim();
// `a + b` has no alias: the prefix `a +` is incomplete, so the whole
// projection keeps the server's expression label.
if (alias && alias.end === aliasText.length && (alias.quoted || !isReservedImplicitAlias(alias.value)) && looksLikeComputedExpression(expression)) {
return { expression, resultName: alias.value };
}
}
return null;
}
function lastTopLevelWhitespaceStart(text: string): number | undefined {
let last: number | undefined;
let depth = 0;
let quote: string | null = null;
let inWhitespace = false;
for (let index = 0; index < text.length; index++) {
const ch = text[index]!;
if (quote) {
if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") quote = ch;
else if (ch === "[") quote = "]";
else if (ch === "(") depth++;
else if (ch === ")") depth--;
const topLevelWhitespace = depth === 0 && /\s/u.test(ch);
if (topLevelWhitespace && !inWhitespace) last = index;
inWhitespace = topLevelWhitespace;
}
return last;
}
function looksLikeComputedExpression(expression: string): boolean {
const trimmed = expression.trim();
if (!trimmed || !hasBalancedExpressionDelimiters(trimmed)) return false;
const identifier = parseQualifiedIdentifier(trimmed);
if (identifier?.end === trimmed.length) return false;
if (/[+\-*/%=<>!|&^,.:]$/u.test(trimmed)) return false;
const finalWord = trimmed.split(/\s+/u).at(-1)?.toUpperCase() ?? "";
if (new Set(["AND", "OR", "XOR", "NOT", "LIKE", "ILIKE", "IS", "IN", "BETWEEN", "WHEN", "THEN", "ELSE", "AS", "COLLATE", "AT", "DIV", "MOD", "REGEXP", "RLIKE"]).has(finalWord)) return false;
const source = parseQualifiedIdentifier(trimmed);
if (!source) return true;
if (source.end === trimmed.length) return false;
const rest = source.rest.trimStart();
return /^[()+\-*/%=<>!|&^:?]/u.test(rest) || /^(?:IS|IN|LIKE|ILIKE|BETWEEN|COLLATE|AT|DIV|MOD|REGEXP|RLIKE)\b/iu.test(rest) || /^(?:CASE|NOT)\b/iu.test(trimmed);
}
function hasBalancedExpressionDelimiters(expression: string): boolean {
let depth = 0;
let quote: string | null = null;
for (const ch of expression) {
if (quote) {
if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") quote = ch;
else if (ch === "[") quote = "]";
else if (ch === "(") depth++;
else if (ch === ")" && --depth < 0) return false;
}
return depth === 0 && quote === null;
}
function isReservedImplicitAlias(alias: string): boolean {
return new Set(["ALL", "AND", "AS", "ASC", "BETWEEN", "CASE", "COLLATE", "DESC", "DISTINCT", "ELSE", "END", "FALSE", "FROM", "IN", "IS", "LIKE", "LIMIT", "NOT", "NULL", "OFFSET", "OR", "ORDER", "THEN", "TRUE", "WHEN", "WHERE", "XOR"]).has(alias.toUpperCase());
}
function parseColumnAlias(rest: string): string | undefined | null {
@@ -138,6 +138,42 @@ describe("queryStore hidden primary key editing", () => {
expect(tab.queryEditabilityReason).toBeUndefined();
});
it("keeps MySQL expression columns read-only without disabling direct columns", async () => {
const sql = "SELECT id, status, extra->>'$.mode' mode, extra->>'$.template' tmpl FROM items";
getColumns.mockResolvedValue([
{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "status", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "extra", data_type: "json", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
]);
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: undefined,
tableName: "items",
selectStar: false,
columns: [
{ sourceName: "id", resultName: "id", expression: "id" },
{ sourceName: "status", resultName: "status", expression: "status" },
{ sourceName: undefined, resultName: "mode", expression: "extra->>'$.mode'" },
{ sourceName: undefined, resultName: "tmpl", expression: "extra->>'$.template'" },
],
},
});
executeMulti.mockResolvedValue([{ columns: ["id", "status", "mode", "tmpl"], rows: [[1, "ok", "fast", "base"]], 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.querySourceColumns).toEqual(["id", "status", undefined, undefined]));
expect(tab.queryEditabilityReason).toBeUndefined();
expect(getColumns).toHaveBeenCalledTimes(1);
expect(listObjects).not.toHaveBeenCalled();
});
it("starts a qualified MySQL star query before slow column metadata finishes", async () => {
const columnsGate = deferred<Awaited<ReturnType<typeof getColumns>>>();
getColumns.mockReturnValue(columnsGate.promise);