fix(grid): keep computed query columns individually read-only
Closes #6132
This commit is contained in:
1 parent
7a21e7e5f9
commit
37f64dbdf4
4 files changed
+383
-23
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user