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

+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 {