diff --git a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts index 8ba2e16a87..eaf1bfca75 100644 --- a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts @@ -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( diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 1bba6bfbae..c6afbe4234 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -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 { diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts index 2127bcda47..f577658341 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts @@ -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>>(); getColumns.mockReturnValue(columnsGate.promise); diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 086ac74e86..969cf938c2 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -339,7 +339,11 @@ fn parse_star_select_column(column: &str, sources: &[FromSource]) -> Option Option { - 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 { source_name: None, source_name_quoted: false, @@ -384,23 +388,167 @@ fn parse_expression_alias(column: &str) -> Option { return Some(ExpressionAlias { expression, result_name: alias.value }); } - for (index, _) in trimmed_end.match_indices('\'') { - if !previous_char(trimmed_end, index).is_some_and(char::is_whitespace) { - continue; + if let Some(index) = last_top_level_whitespace_start(trimmed_end) { + let alias_text = trimmed_end[index..].trim(); + 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 } +fn last_top_level_whitespace_start(text: &str) -> Option { + let mut last = None; + let mut depth = 0i32; + let mut quote: Option = 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 = 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> { let trimmed = rest.trim(); 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] fn accepts_unquoted_unicode_aliases_in_mysql_and_sql_server_queries() { for sql in [