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
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes } from "@/lib/sql/editableQueryHiddenKeys";
|
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", () => {
|
describe("editable query hidden primary keys", () => {
|
||||||
it("appends quoted MySQL primary keys without changing the visible projection", () => {
|
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", () => {
|
it("resolves appended aliases to result indexes", () => {
|
||||||
expect(
|
expect(
|
||||||
hiddenResultColumnIndexes(
|
hiddenResultColumnIndexes(
|
||||||
|
|||||||
@@ -261,7 +261,8 @@ function parseStarSelectColumn(col: string, sources?: EditableQuerySource[]): Ed
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseComputedSelectColumn(col: string): EditableQueryColumn | null {
|
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;
|
if (!alias) return null;
|
||||||
return {
|
return {
|
||||||
sourceName: undefined,
|
sourceName: undefined,
|
||||||
@@ -273,14 +274,83 @@ function parseComputedSelectColumn(col: string): EditableQueryColumn | null {
|
|||||||
|
|
||||||
function parseExpressionAlias(col: string): { expression: string; resultName: string } | 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 asMatch = col.match(/\bAS\s+((?:"[^"]+")|(?:`[^`]+`)|(?:\[[^\]]+\])|(?:'(?:''|[^'])*')|(?:[\p{ID_Start}_][\p{ID_Continue}$]*))\s*$/iu);
|
||||||
const implicitStringMatch = asMatch ? undefined : col.match(/\s+('(?:''|[^'])*')\s*$/u);
|
if (asMatch?.index !== undefined) {
|
||||||
const aliasText = asMatch?.[1] ?? implicitStringMatch?.[1];
|
const alias = readSelectAlias(asMatch[1]!);
|
||||||
const expressionEnd = asMatch?.index ?? implicitStringMatch?.index;
|
const expression = col.slice(0, asMatch.index).trim();
|
||||||
if (!aliasText || expressionEnd === undefined) return null;
|
if (alias && alias.end === asMatch[1]!.length && expression) return { expression, resultName: alias.value };
|
||||||
const alias = readSelectAlias(aliasText);
|
}
|
||||||
if (!alias || alias.end !== aliasText.length) return null;
|
|
||||||
const expression = col.slice(0, expressionEnd).trim();
|
const trimmed = col.trimEnd();
|
||||||
return expression ? { expression, resultName: alias.value } : null;
|
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 {
|
function parseColumnAlias(rest: string): string | undefined | null {
|
||||||
|
|||||||
@@ -138,6 +138,42 @@ describe("queryStore hidden primary key editing", () => {
|
|||||||
expect(tab.queryEditabilityReason).toBeUndefined();
|
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 () => {
|
it("starts a qualified MySQL star query before slow column metadata finishes", async () => {
|
||||||
const columnsGate = deferred<Awaited<ReturnType<typeof getColumns>>>();
|
const columnsGate = deferred<Awaited<ReturnType<typeof getColumns>>>();
|
||||||
getColumns.mockReturnValue(columnsGate.promise);
|
getColumns.mockReturnValue(columnsGate.promise);
|
||||||
|
|||||||
@@ -339,7 +339,11 @@ fn parse_star_select_column(column: &str, sources: &[FromSource]) -> Option<Edit
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_computed_select_column(column: &str) -> Option<EditableQueryColumn> {
|
fn parse_computed_select_column(column: &str) -> Option<EditableQueryColumn> {
|
||||||
let alias = parse_expression_alias(column)?;
|
let alias = parse_expression_alias(column).or_else(|| {
|
||||||
|
let expression = column.trim();
|
||||||
|
looks_like_computed_expression(expression)
|
||||||
|
.then(|| ExpressionAlias { expression: expression.to_string(), result_name: expression.to_string() })
|
||||||
|
})?;
|
||||||
Some(EditableQueryColumn {
|
Some(EditableQueryColumn {
|
||||||
source_name: None,
|
source_name: None,
|
||||||
source_name_quoted: false,
|
source_name_quoted: false,
|
||||||
@@ -384,23 +388,167 @@ fn parse_expression_alias(column: &str) -> Option<ExpressionAlias> {
|
|||||||
return Some(ExpressionAlias { expression, result_name: alias.value });
|
return Some(ExpressionAlias { expression, result_name: alias.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (index, _) in trimmed_end.match_indices('\'') {
|
if let Some(index) = last_top_level_whitespace_start(trimmed_end) {
|
||||||
if !previous_char(trimmed_end, index).is_some_and(char::is_whitespace) {
|
let alias_text = trimmed_end[index..].trim();
|
||||||
continue;
|
if let Some(alias) = read_select_alias(alias_text) {
|
||||||
|
let expression = trimmed_end[..index].trim();
|
||||||
|
// `a + b` has no alias: the prefix `a +` is incomplete, so the
|
||||||
|
// whole projection keeps the server's expression label.
|
||||||
|
if alias.end == alias_text.len()
|
||||||
|
&& (alias.quoted || !is_reserved_implicit_alias(&alias.value))
|
||||||
|
&& looks_like_computed_expression(expression)
|
||||||
|
{
|
||||||
|
return Some(ExpressionAlias { expression: expression.to_string(), result_name: alias.value });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let alias = read_select_alias(&trimmed_end[index..])?;
|
|
||||||
if alias.end != trimmed_end.len() - index {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let expression = trimmed_end[..index].trim().to_string();
|
|
||||||
if expression.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
return Some(ExpressionAlias { expression, result_name: alias.value });
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn last_top_level_whitespace_start(text: &str) -> Option<usize> {
|
||||||
|
let mut last = None;
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut quote: Option<char> = None;
|
||||||
|
let mut in_whitespace = false;
|
||||||
|
|
||||||
|
for (index, ch) in text.char_indices() {
|
||||||
|
if let Some(close) = quote {
|
||||||
|
if ch == close {
|
||||||
|
quote = None;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match ch {
|
||||||
|
'\'' | '"' | '`' => quote = Some(ch),
|
||||||
|
'[' => quote = Some(']'),
|
||||||
|
'(' => depth += 1,
|
||||||
|
')' => depth -= 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let top_level_whitespace = depth == 0 && ch.is_whitespace();
|
||||||
|
if top_level_whitespace && !in_whitespace {
|
||||||
|
last = Some(index);
|
||||||
|
}
|
||||||
|
in_whitespace = top_level_whitespace;
|
||||||
|
}
|
||||||
|
last
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_computed_expression(expression: &str) -> bool {
|
||||||
|
let expression = expression.trim();
|
||||||
|
if expression.is_empty() || !has_balanced_expression_delimiters(expression) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if parse_qualified_identifier(expression).is_some_and(|identifier| identifier.end == expression.len()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if expression.chars().next_back().is_some_and(|ch| {
|
||||||
|
matches!(ch, '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '|' | '&' | '^' | ',' | '.' | ':')
|
||||||
|
}) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let final_word = expression.split_whitespace().next_back().unwrap_or_default().to_ascii_uppercase();
|
||||||
|
if matches!(
|
||||||
|
final_word.as_str(),
|
||||||
|
"AND"
|
||||||
|
| "OR"
|
||||||
|
| "XOR"
|
||||||
|
| "NOT"
|
||||||
|
| "LIKE"
|
||||||
|
| "ILIKE"
|
||||||
|
| "IS"
|
||||||
|
| "IN"
|
||||||
|
| "BETWEEN"
|
||||||
|
| "WHEN"
|
||||||
|
| "THEN"
|
||||||
|
| "ELSE"
|
||||||
|
| "AS"
|
||||||
|
| "COLLATE"
|
||||||
|
| "AT"
|
||||||
|
| "DIV"
|
||||||
|
| "MOD"
|
||||||
|
| "REGEXP"
|
||||||
|
| "RLIKE"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(source) = parse_qualified_identifier(expression) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if source.end == expression.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let rest = expression[source.end..].trim_start();
|
||||||
|
matches!(
|
||||||
|
rest.chars().next(),
|
||||||
|
Some('(' | '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '|' | '&' | '^' | ':' | '?')
|
||||||
|
) || ["IS", "IN", "LIKE", "ILIKE", "BETWEEN", "COLLATE", "AT", "DIV", "MOD", "REGEXP", "RLIKE"]
|
||||||
|
.iter()
|
||||||
|
.any(|keyword| starts_with_keyword(rest, keyword))
|
||||||
|
|| starts_with_keyword(expression, "CASE")
|
||||||
|
|| starts_with_keyword(expression, "NOT")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_balanced_expression_delimiters(expression: &str) -> bool {
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut quote: Option<char> = None;
|
||||||
|
for ch in expression.chars() {
|
||||||
|
if let Some(close) = quote {
|
||||||
|
if ch == close {
|
||||||
|
quote = None;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match ch {
|
||||||
|
'\'' | '"' | '`' => quote = Some(ch),
|
||||||
|
'[' => quote = Some(']'),
|
||||||
|
'(' => depth += 1,
|
||||||
|
')' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth < 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depth == 0 && quote.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_reserved_implicit_alias(alias: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
alias.to_ascii_uppercase().as_str(),
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_column_alias(rest: &str) -> Option<Option<String>> {
|
fn parse_column_alias(rest: &str) -> Option<Option<String>> {
|
||||||
let trimmed = rest.trim();
|
let trimmed = rest.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -1184,6 +1332,68 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_mysql_json_expressions_read_only_with_or_without_aliases() {
|
||||||
|
for (sql, expected) in [
|
||||||
|
(
|
||||||
|
"select id, status, extra->>'$.mode' mode, extra->>'$.template' tmpl from jobs",
|
||||||
|
vec![
|
||||||
|
column(Some("id"), false, None, None, "id", "id"),
|
||||||
|
column(Some("status"), false, None, None, "status", "status"),
|
||||||
|
column(None, false, None, None, "mode", "extra->>'$.mode'"),
|
||||||
|
column(None, false, None, None, "tmpl", "extra->>'$.template'"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"select id, status, extra->>'$.mode', extra->>'$.template' from jobs",
|
||||||
|
vec![
|
||||||
|
column(Some("id"), false, None, None, "id", "id"),
|
||||||
|
column(Some("status"), false, None, None, "status", "status"),
|
||||||
|
column(None, false, None, None, "extra->>'$.mode'", "extra->>'$.mode'"),
|
||||||
|
column(None, false, None, None, "extra->>'$.template'", "extra->>'$.template'"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let result = analyze_editable_query_editability(sql);
|
||||||
|
|
||||||
|
assert!(result.editable, "{sql}: {:?}", result.reason);
|
||||||
|
assert_eq!(result.analysis.unwrap().columns, expected, "{sql}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_computed_aliases_separate_from_writeable_source_columns() {
|
||||||
|
let result = analyze_editable_query_editability(
|
||||||
|
"select id, status as state, lower(name) normalized, count_value + 1 from jobs",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.editable);
|
||||||
|
assert_eq!(
|
||||||
|
result.analysis.unwrap().columns,
|
||||||
|
vec![
|
||||||
|
column(Some("id"), false, None, None, "id", "id"),
|
||||||
|
column(Some("status"), false, None, None, "state", "status"),
|
||||||
|
column(None, false, None, None, "normalized", "lower(name)"),
|
||||||
|
column(None, false, None, None, "count_value + 1", "count_value + 1"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_unaliased_computed_result_labels_without_binding_them() {
|
||||||
|
let result = analyze_editable_query_editability("select id, id + status, lower(status) from jobs");
|
||||||
|
|
||||||
|
assert!(result.editable);
|
||||||
|
assert_eq!(
|
||||||
|
result.analysis.unwrap().columns,
|
||||||
|
vec![
|
||||||
|
column(Some("id"), false, None, None, "id", "id"),
|
||||||
|
column(None, false, None, None, "id + status", "id + status"),
|
||||||
|
column(None, false, None, None, "lower(status)", "lower(status)"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_unquoted_unicode_aliases_in_mysql_and_sql_server_queries() {
|
fn accepts_unquoted_unicode_aliases_in_mysql_and_sql_server_queries() {
|
||||||
for sql in [
|
for sql in [
|
||||||
|
|||||||
Reference in new issue
Block a user