diff --git a/Cargo.lock b/Cargo.lock index bebd34c1d1..4b11079aaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -1790,6 +1799,7 @@ dependencies = [ "rustls 0.23.40", "serde", "serde_json", + "sqlparser", "sqlx", "tiberius", "tokio", @@ -4745,6 +4755,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5447,6 +5466,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -5650,6 +5679,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "redis" version = "0.32.7" @@ -6968,6 +7017,16 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sqlparser" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +dependencies = [ + "log", + "recursive", +] + [[package]] name = "sqlx" version = "0.8.6" @@ -7205,6 +7264,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "string_cache" version = "0.9.0" diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 52de46ac2c..fb54bb1c0d 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -21,6 +21,7 @@ import { buildSqlCompletionItemsFromContext, getSqlFunctionSignatureHelp, getSqlCompletionContext, + getSqlCompletionResultValidFor, shouldAutoOpenSqlCompletion, } from "@/lib/sqlCompletion"; import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation"; @@ -39,7 +40,16 @@ import { fontSizeFromWheelDelta, } from "@/lib/editorZoom"; import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry"; +import * as api from "@/lib/api"; +import { + areSqlSemanticDiagnosticsEqual, + buildSqlParserErrorDiagnostic, + buildSqlSemanticDiagnostics, + shouldRunSqlSemanticDiagnostics, + type SqlSemanticDiagnostic, +} from "@/lib/sqlSemanticDiagnostics"; import type { SqlCompletionColumn } from "@/lib/sqlCompletion"; +import type { SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; const props = defineProps<{ modelValue: string; @@ -99,6 +109,11 @@ let diagnosticComp: import("@codemirror/state").Compartment | null = null; let buildSqlDiagnosticExtension: (() => import("@codemirror/state").Extension) | null = null; let buildSqlSignatureExtension: (() => import("@codemirror/state").Extension) | null = null; let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion; +let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null; +let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; +let semanticDiagnostics: SqlSemanticDiagnostic[] = []; +let semanticDiagnosticTimer: ReturnType | null = null; +let semanticDiagnosticRunId = 0; function editorThemeAppearance() { return isDark.value ? "dark" : "light"; @@ -245,18 +260,18 @@ function identifierRangeAt(sql: string, pos: number): { from: number; to: number return { from, to, text }; } -function completionCacheKey(table: { name: string; schema?: string }) { +function completionCacheKey(table: { name: string; schema?: string | null }) { return table.schema ? `${table.schema}.${table.name}` : table.name; } -async function ensureColumnsForTable(table: { name: string; schema?: string }) { +async function ensureColumnsForTable(table: { name: string; schema?: string | null }) { const cacheKey = completionCacheKey(table); if (cachedColumnsByTable.has(cacheKey) || !props.connectionId || !props.database) return; const columns = await connectionStore.listCompletionColumns( props.connectionId, props.database, table.name, - table.schema, + table.schema ?? undefined, ); cachedColumnsByTable.set(cacheKey, columns); } @@ -409,6 +424,131 @@ function sqlErrorDecorationRange(currentState: import("@codemirror/state").Edito ]; } +function sqlTextSpanToRange(sql: string, span: SqlTextSpan): { from: number; to: number } | null { + if (!span.start_line || !span.start_column) return null; + const from = lineColumnToOffset(sql, { line: span.start_line - 1, column: span.start_column - 1 }); + const to = lineColumnToOffset(sql, { + line: Math.max(span.end_line - 1, span.start_line - 1), + column: Math.max(span.end_column, span.start_column), + }); + if (from == null || to == null || to <= from) return null; + return { from, to }; +} + +function sqlSemanticDecorationRanges(currentState: import("@codemirror/state").EditorState) { + const sql = currentState.doc.toString(); + return semanticDiagnostics + .map((diagnostic) => { + const range = sqlTextSpanToRange(sql, diagnostic.span); + return range ? { ...range, message: diagnostic.message, severity: diagnostic.severity } : null; + }) + .filter((range): range is { from: number; to: number; message: string; severity: "error" | "warning" } => !!range); +} + +function reconfigureDiagnostics() { + if (!view.value) return; + if (setSqlDiagnosticsEffect) { + view.value.dispatch({ + effects: setSqlDiagnosticsEffect.of(semanticDiagnostics), + }); + return; + } + if (!diagnosticComp || !buildSqlDiagnosticExtension) return; + view.value.dispatch({ + effects: diagnosticComp.reconfigure(buildSqlDiagnosticExtension()), + }); +} + +function setSemanticDiagnostics(next: SqlSemanticDiagnostic[]) { + if (areSqlSemanticDiagnosticsEqual(semanticDiagnostics, next)) return; + semanticDiagnostics = next; + reconfigureDiagnostics(); +} + +async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]) { + if (!props.connectionId || !props.database) return tables; + + const enriched: SqlTableReference[] = []; + for (const table of tables) { + if (table.schema) { + enriched.push(table); + continue; + } + const cached = cachedTables.find((item) => item.name.toLowerCase() === table.name.toLowerCase()); + if (cached?.schema) { + enriched.push({ ...table, schema: cached.schema }); + continue; + } + try { + const matches = await connectionStore.listCompletionTables( + props.connectionId, + props.database, + table.name, + MAX_COMPLETION_TABLES, + ); + cachedTables = [...cachedTables, ...matches]; + const match = matches.find((item) => item.name.toLowerCase() === table.name.toLowerCase()); + enriched.push(match?.schema ? { ...table, schema: match.schema } : table); + } catch { + enriched.push(table); + } + } + return enriched; +} + +async function refreshSemanticDiagnostics() { + const currentView = view.value; + const runId = ++semanticDiagnosticRunId; + if (!currentView || !props.connectionId || !props.database) { + setSemanticDiagnostics([]); + return; + } + + const sql = currentView.state.doc.toString(); + if (!sql.trim()) { + setSemanticDiagnostics([]); + return; + } + if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head)) { + scheduleSemanticDiagnostics(1200); + return; + } + if (codeMirrorCompletionStatus?.(currentView.state)) { + scheduleSemanticDiagnostics(900); + return; + } + + try { + const analysis = await api.analyzeSqlReferences(sql, props.formatDialect ?? props.dialect ?? "generic"); + if (runId !== semanticDiagnosticRunId) return; + + const tables = await enrichSemanticDiagnosticTables(analysis.tables); + await Promise.all(tables.map((table) => ensureColumnsForTable(table))); + if (runId !== semanticDiagnosticRunId) return; + + const enrichedAnalysis: SqlReferenceAnalysis = { ...analysis, tables }; + setSemanticDiagnostics( + buildSqlSemanticDiagnostics(enrichedAnalysis, { + tables: cachedTables, + columnsByTable: cachedColumnsByTable, + }), + ); + } catch (error) { + if (runId === semanticDiagnosticRunId) { + const diagnostic = buildSqlParserErrorDiagnostic(error, sql); + setSemanticDiagnostics(diagnostic ? [diagnostic] : []); + } + } +} + +function scheduleSemanticDiagnostics(delay = 500) { + if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer); + semanticDiagnosticTimer = setTimeout(() => { + semanticDiagnosticTimer = null; + void refreshSemanticDiagnostics(); + }, delay); +} + async function formatCurrentSql() { const currentView = view.value; if (!currentView) return; @@ -563,7 +703,7 @@ async function provideSqlCompletions( boost: item.boost, }, ), - validFor: /^[\w$]*$/, + validFor: getSqlCompletionResultValidFor(fullDoc, position), }; } catch { return null; @@ -580,10 +720,10 @@ onMounted(async () => { const [ { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration }, - { EditorState, Compartment, Prec, StateField }, + { EditorState, Compartment, Prec, StateEffect, StateField }, { sql, MSSQL, MySQL, PostgreSQL, SQLDialect }, { basicSetup }, - { autocompletion, startCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion }, + { autocompletion, startCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus }, { indentWithTab }, { bracketMatching }, ] = await Promise.all([ @@ -603,29 +743,44 @@ onMounted(async () => { readOnlyComp = new Compartment(); runKeymapComp = new Compartment(); diagnosticComp = new Compartment(); + setSqlDiagnosticsEffect = StateEffect.define(); + codeMirrorCompletionStatus = completionStatus; const diagnosticTheme = EditorView.baseTheme({ ".cm-sql-error": { textDecoration: "underline wavy var(--destructive)", textUnderlineOffset: "3px", }, + ".cm-sql-semantic-warning": { + textDecoration: "underline wavy hsl(var(--warning, 38 92% 50%))", + textUnderlineOffset: "3px", + }, }); buildSqlDiagnosticExtension = () => { - const buildDecorations = (state: import("@codemirror/state").EditorState) => - Decoration.set( - sqlErrorDecorationRange(state).map((range) => - Decoration.mark({ - class: "cm-sql-error", - attributes: { title: range.message }, - }).range(range.from, range.to), - ), + const diagnosticEffect = setSqlDiagnosticsEffect; + const buildDecorations = (state: import("@codemirror/state").EditorState) => { + const errorDecorations = sqlErrorDecorationRange(state).map((range) => + Decoration.mark({ + class: "cm-sql-error", + attributes: { title: range.message }, + }).range(range.from, range.to), ); + const semanticDecorations = sqlSemanticDecorationRanges(state).map((range) => + Decoration.mark({ + class: range.severity === "error" ? "cm-sql-error" : "cm-sql-semantic-warning", + attributes: { title: range.message }, + }).range(range.from, range.to), + ); + return Decoration.set([...errorDecorations, ...semanticDecorations], true); + }; const field = StateField.define({ create: buildDecorations, update(value, transaction) { - return transaction.docChanged ? buildDecorations(transaction.state) : value; + const diagnosticsChanged = + !!diagnosticEffect && transaction.effects.some((effect) => effect.is(diagnosticEffect)); + return transaction.docChanged || diagnosticsChanged ? buildDecorations(transaction.state) : value; }, provide: (field) => EditorView.decorations.from(field), }); @@ -691,6 +846,7 @@ onMounted(async () => { EditorView.updateListener.of((update) => { if (update.docChanged) { emit("update:modelValue", update.state.doc.toString()); + scheduleSemanticDiagnostics(); let insertedText = ""; update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { insertedText += inserted.toString(); @@ -858,6 +1014,7 @@ onMounted(async () => { syncEditorFontCssVars(liveFontSize.value, ss.fontFamily); cachedTables = []; + scheduleSemanticDiagnostics(); }); watch( @@ -881,10 +1038,7 @@ watch( watch( () => props.executionError, () => { - if (!view.value || !diagnosticComp || !buildSqlDiagnosticExtension) return; - view.value.dispatch({ - effects: diagnosticComp.reconfigure(buildSqlDiagnosticExtension()), - }); + reconfigureDiagnostics(); }, ); @@ -892,6 +1046,8 @@ watch( () => props.connectionId, () => { refreshCompletionCache(); + setSemanticDiagnostics([]); + scheduleSemanticDiagnostics(); }, ); @@ -899,6 +1055,8 @@ watch( () => props.database, () => { refreshCompletionCache(); + setSemanticDiagnostics([]); + scheduleSemanticDiagnostics(); }, ); @@ -937,6 +1095,7 @@ watch( onBeforeUnmount(() => { zoomCommitScheduler.dispose(); + if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer); view.value?.destroy(); }); diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 6fcbd68527..e69b544f03 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -86,6 +86,7 @@ export const executeScript = forward("executeScript"); export const executeInTransaction = forward("executeInTransaction"); export const cancelQuery = forward("cancelQuery"); export const closeQuerySession = forward("closeQuerySession"); +export const analyzeSqlReferences = forward("analyzeSqlReferences"); // AI export const aiComplete = forward("aiComplete"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index c9cf6ffbca..f27786c449 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -10,6 +10,7 @@ import type { ForeignKeyInfo, TriggerInfo, QueryResult, + SqlReferenceAnalysis, InstalledPlugin, JdbcDriverInfo, JdbcPluginStatus, @@ -398,6 +399,10 @@ export async function cancelQuery(executionId: string): Promise { return post("/api/query/cancel", { executionId }); } +export async function analyzeSqlReferences(sql: string, dialect?: string): Promise { + return post("/api/query/analyze-sql-references", { sql, dialect }); +} + // --------------------------------------------------------------------------- // AI // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 1e5b75e5ec..d30ece40bc 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -418,9 +418,17 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea const previousChar = sql[cursor - 1]; if (!previousChar) return false; if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true; + if (/[,;()[\]]/.test(previousChar)) return false; + const context = getSqlCompletionContext(sql, cursor); + if (context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions || context.suggestTables) return true; return /[\w$.]/.test(previousChar); } +export function getSqlCompletionResultValidFor(sql: string, cursor: number): RegExp | undefined { + const context = getSqlCompletionContext(sql, cursor); + return context.suggestTables ? undefined : /^[\w$]*$/; +} + export function getSqlFunctionSignatureHelp(sql: string, cursor: number): SqlFunctionSignatureHelp | null { const beforeCursor = sql.slice(0, cursor); const openParenIndex = findActiveFunctionOpenParen(beforeCursor); diff --git a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts new file mode 100644 index 0000000000..22a7bdde42 --- /dev/null +++ b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts @@ -0,0 +1,137 @@ +import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion"; +import { getSqlCompletionContext } from "@/lib/sqlCompletion"; +import type { SqlColumnReference, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; + +export interface SqlSemanticDiagnostic { + span: SqlTextSpan; + message: string; + severity: "error" | "warning"; +} + +export interface SqlSemanticDiagnosticSchema { + tables: SqlCompletionTable[]; + columnsByTable: Map; +} + +export function buildSqlSemanticDiagnostics( + analysis: SqlReferenceAnalysis, + schema: SqlSemanticDiagnosticSchema, +): SqlSemanticDiagnostic[] { + const diagnostics: SqlSemanticDiagnostic[] = []; + const tables = analysis.tables.filter((table) => table.name.trim()); + const knownTables = new Map(); + + for (const table of tables) { + knownTables.set(normalizeName(table.name), table); + if (table.alias) knownTables.set(normalizeName(table.alias), table); + if (table.schema) knownTables.set(normalizeName(`${table.schema}.${table.name}`), table); + } + + for (const column of analysis.columns) { + const table = resolveColumnTable(column, tables, knownTables); + if (!table) continue; + + const columns = columnsForTable(table, schema.columnsByTable); + if (!columns) continue; + + const columnNames = new Set(columns.map((item) => normalizeName(item.name))); + if (columnNames.has(normalizeName(column.name))) continue; + + const displayName = column.qualifier ? `${column.qualifier}.${column.name}` : column.name; + diagnostics.push({ + span: column.span, + message: `Unknown column ${displayName}`, + severity: "warning", + }); + } + + return diagnostics; +} + +export function buildSqlParserErrorDiagnostic(error: unknown, sql: string): SqlSemanticDiagnostic | null { + const message = errorMessage(error); + const location = /\bat Line:\s*(\d+),\s*Column:\s*(\d+)\b/i.exec(message); + if (!location) return null; + + const startLine = Number.parseInt(location[1], 10); + const startColumn = Number.parseInt(location[2], 10); + if (!Number.isFinite(startLine) || !Number.isFinite(startColumn) || startLine < 1 || startColumn < 1) return null; + + const lineText = sql.split(/\r?\n/)[startLine - 1] ?? ""; + const startIndex = Math.max(startColumn - 1, 0); + const token = /^[\w$]+/.exec(lineText.slice(startIndex))?.[0]; + const tokenLength = Math.max(token?.length ?? 1, 1); + + return { + span: { + start_line: startLine, + start_column: startColumn, + end_line: startLine, + end_column: startColumn + tokenLength - 1, + }, + message, + severity: "error", + }; +} + +export function areSqlSemanticDiagnosticsEqual( + left: readonly SqlSemanticDiagnostic[], + right: readonly SqlSemanticDiagnostic[], +): boolean { + if (left.length !== right.length) return false; + return left.every((item, index) => { + const other = right[index]; + return ( + !!other && + item.message === other.message && + item.severity === other.severity && + item.span.start_line === other.span.start_line && + item.span.start_column === other.span.start_column && + item.span.end_line === other.span.end_line && + item.span.end_column === other.span.end_column + ); + }); +} + +export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number): boolean { + const context = getSqlCompletionContext(sql, cursor); + if (context.suggestTables || context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions) return false; + if (context.qualifier) return false; + return true; +} + +function resolveColumnTable( + column: SqlColumnReference, + tables: SqlTableReference[], + knownTables: Map, +): SqlTableReference | null { + if (column.qualifier) { + return knownTables.get(normalizeName(column.qualifier)) ?? null; + } + if (tables.length !== 1) return null; + return tables[0]; +} + +function columnsForTable( + table: SqlTableReference, + columnsByTable: Map, +): SqlCompletionColumn[] | null { + const keys = table.schema ? [`${table.schema}.${table.name}`, table.name] : [table.name]; + for (const key of keys) { + const columns = columnsByTable.get(key) ?? columnsByTable.get(normalizeName(key)); + if (columns) return columns; + } + return null; +} + +function normalizeName(value: string): string { + let normalized = value; + while (normalized && `"'\`[]`.includes(normalized[0])) normalized = normalized.slice(1); + while (normalized && `"'\`[]`.includes(normalized[normalized.length - 1])) normalized = normalized.slice(0, -1); + return normalized.toLowerCase(); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 4192f97765..83b4f62021 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -12,6 +12,7 @@ import type { ForeignKeyInfo, TriggerInfo, QueryResult, + SqlReferenceAnalysis, InstalledPlugin, JdbcDriverInfo, JdbcPluginStatus, @@ -297,6 +298,10 @@ export async function executeInTransaction( return invoke("execute_in_transaction", { connectionId, database, statements, schema }); } +export async function analyzeSqlReferences(sql: string, dialect?: string): Promise { + return invoke("analyze_sql_references", { sql, dialect }); +} + export async function listIndexes( connectionId: string, database: string, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 506cf7deaa..d3f4b964d8 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -203,6 +203,31 @@ export interface QueryResult { has_more?: boolean; } +export interface SqlTextSpan { + start_line: number; + start_column: number; + end_line: number; + end_column: number; +} + +export interface SqlTableReference { + name: string; + schema?: string | null; + alias?: string | null; + span: SqlTextSpan; +} + +export interface SqlColumnReference { + name: string; + qualifier?: string | null; + span: SqlTextSpan; +} + +export interface SqlReferenceAnalysis { + tables: SqlTableReference[]; + columns: SqlColumnReference[]; +} + export type TreeNodeType = | "connection" | "connection-group" diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 289b742db6..d2ee2ffe38 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -19,6 +19,7 @@ rust_decimal = { version = "1", features = ["serde"] } anyhow = "1" uuid = { version = "1", features = ["v4", "serde"] } sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "mysql", "postgres", "sqlite", "json", "chrono", "uuid", "rust_decimal"] } +sqlparser = "0.62.0" redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp"] } rustls = { version = "0.23", features = ["aws-lc-rs"] } duckdb = "1.3.2" diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index ab6fc49445..7ef109941b 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod redis_ops; pub mod saved_sql; pub mod schema; pub mod sql; +pub mod sql_analysis; pub mod storage; pub mod table_import; pub mod transfer; diff --git a/crates/dbx-core/src/sql_analysis.rs b/crates/dbx-core/src/sql_analysis.rs new file mode 100644 index 0000000000..88075ac21c --- /dev/null +++ b/crates/dbx-core/src/sql_analysis.rs @@ -0,0 +1,379 @@ +use serde::{Deserialize, Serialize}; +use sqlparser::ast::{ + Expr, FunctionArg, FunctionArgExpr, FunctionArguments, GroupByExpr, Ident, JoinConstraint, JoinOperator, + ObjectName, ObjectNamePart, OrderByKind, Query, Select, SelectItem, SetExpr, Statement, TableFactor, + TableWithJoins, +}; +use sqlparser::dialect::{ + ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect, +}; +use sqlparser::parser::Parser; +use sqlparser::tokenizer::Span; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SqlReferenceAnalysis { + pub tables: Vec, + pub columns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SqlTableReference { + pub name: String, + pub schema: Option, + pub alias: Option, + pub span: SqlTextSpan, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SqlColumnReference { + pub name: String, + pub qualifier: Option, + pub span: SqlTextSpan, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SqlTextSpan { + pub start_line: usize, + pub start_column: usize, + pub end_line: usize, + pub end_column: usize, +} + +impl From for SqlTextSpan { + fn from(span: Span) -> Self { + Self { + start_line: span.start.line as usize, + start_column: span.start.column as usize, + end_line: span.end.line as usize, + end_column: span.end.column as usize, + } + } +} + +#[derive(Default)] +struct Analyzer { + tables: Vec, + columns: Vec, +} + +pub fn analyze_sql_references(sql: &str, dialect: Option<&str>) -> Result { + let statements = match normalize_dialect(dialect).as_str() { + "postgres" => Parser::parse_sql(&PostgreSqlDialect {}, sql), + "mysql" => Parser::parse_sql(&MySqlDialect {}, sql), + "sqlite" => Parser::parse_sql(&SQLiteDialect {}, sql), + "sqlserver" => Parser::parse_sql(&MsSqlDialect {}, sql), + "clickhouse" => Parser::parse_sql(&ClickHouseDialect {}, sql), + "duckdb" => Parser::parse_sql(&DuckDbDialect {}, sql), + _ => Parser::parse_sql(&GenericDialect {}, sql), + } + .map_err(|err| err.to_string())?; + + let mut analyzer = Analyzer::default(); + for statement in statements { + analyzer.visit_statement(&statement); + } + + Ok(SqlReferenceAnalysis { tables: analyzer.tables, columns: analyzer.columns }) +} + +fn normalize_dialect(dialect: Option<&str>) -> String { + match dialect.unwrap_or("generic").to_ascii_lowercase().as_str() { + "postgres" | "postgresql" | "redshift" | "opengauss" | "gaussdb" | "highgo" => "postgres".to_string(), + "mysql" | "mariadb" | "doris" | "starrocks" | "oceanbase" => "mysql".to_string(), + "sqlite" => "sqlite".to_string(), + "sqlserver" | "mssql" => "sqlserver".to_string(), + "clickhouse" => "clickhouse".to_string(), + "duckdb" => "duckdb".to_string(), + _ => "generic".to_string(), + } +} + +impl Analyzer { + fn visit_statement(&mut self, statement: &Statement) { + if let Statement::Query(query) = statement { + self.visit_query(query); + } + } + + fn visit_query(&mut self, query: &Query) { + if let Some(with) = &query.with { + for cte in &with.cte_tables { + self.visit_query(&cte.query); + } + } + self.visit_set_expr(&query.body); + if let Some(order_by) = &query.order_by { + if let OrderByKind::Expressions(exprs) = &order_by.kind { + for expr in exprs { + self.visit_expr(&expr.expr); + } + } + } + } + + fn visit_set_expr(&mut self, set_expr: &SetExpr) { + match set_expr { + SetExpr::Select(select) => self.visit_select(select), + SetExpr::Query(query) => self.visit_query(query), + SetExpr::SetOperation { left, right, .. } => { + self.visit_set_expr(left); + self.visit_set_expr(right); + } + _ => {} + } + } + + fn visit_select(&mut self, select: &Select) { + for table in &select.from { + self.visit_table_with_joins(table); + } + + for item in &select.projection { + match item { + SelectItem::UnnamedExpr(expr) + | SelectItem::ExprWithAlias { expr, .. } + | SelectItem::ExprWithAliases { expr, .. } => self.visit_expr(expr), + _ => {} + } + } + + if let Some(expr) = &select.prewhere { + self.visit_expr(expr); + } + if let Some(expr) = &select.selection { + self.visit_expr(expr); + } + if let GroupByExpr::Expressions(exprs, _) = &select.group_by { + for expr in exprs { + self.visit_expr(expr); + } + } + for expr in &select.cluster_by { + self.visit_expr(expr); + } + for expr in &select.distribute_by { + self.visit_expr(expr); + } + for expr in &select.sort_by { + self.visit_expr(&expr.expr); + } + if let Some(expr) = &select.having { + self.visit_expr(expr); + } + if let Some(expr) = &select.qualify { + self.visit_expr(expr); + } + } + + fn visit_table_with_joins(&mut self, table: &TableWithJoins) { + self.visit_table_factor(&table.relation); + for join in &table.joins { + self.visit_table_factor(&join.relation); + self.visit_join_operator(&join.join_operator); + } + } + + fn visit_join_operator(&mut self, operator: &JoinOperator) { + match operator { + JoinOperator::Join(constraint) + | JoinOperator::Inner(constraint) + | JoinOperator::Left(constraint) + | JoinOperator::LeftOuter(constraint) + | JoinOperator::Right(constraint) + | JoinOperator::RightOuter(constraint) + | JoinOperator::FullOuter(constraint) + | JoinOperator::CrossJoin(constraint) + | JoinOperator::Semi(constraint) + | JoinOperator::LeftSemi(constraint) + | JoinOperator::RightSemi(constraint) + | JoinOperator::Anti(constraint) + | JoinOperator::LeftAnti(constraint) + | JoinOperator::RightAnti(constraint) + | JoinOperator::StraightJoin(constraint) => self.visit_join_constraint(constraint), + JoinOperator::AsOf { match_condition, constraint } => { + self.visit_expr(match_condition); + self.visit_join_constraint(constraint); + } + _ => {} + } + } + + fn visit_join_constraint(&mut self, constraint: &JoinConstraint) { + match constraint { + JoinConstraint::On(expr) => self.visit_expr(expr), + JoinConstraint::Using(names) => { + for name in names { + if let Some(ident) = object_name_last_ident(name) { + self.push_column(None, ident); + } + } + } + _ => {} + } + } + + fn visit_table_factor(&mut self, factor: &TableFactor) { + match factor { + TableFactor::Table { name, alias, args, .. } => { + if args.is_none() { + if let Some(table) = table_reference_from_name(name, alias.as_ref().map(|a| a.name.value.clone())) { + self.tables.push(table); + } + } + } + TableFactor::Derived { subquery, .. } => self.visit_query(subquery), + TableFactor::NestedJoin { table_with_joins, .. } => self.visit_table_with_joins(table_with_joins), + TableFactor::TableFunction { expr, .. } => self.visit_expr(expr), + TableFactor::Function { args, .. } => { + for arg in args { + self.visit_function_arg(arg); + } + } + TableFactor::UNNEST { array_exprs, .. } => { + for expr in array_exprs { + self.visit_expr(expr); + } + } + _ => {} + } + } + + fn visit_expr(&mut self, expr: &Expr) { + match expr { + Expr::Identifier(ident) => self.push_column(None, ident), + Expr::CompoundIdentifier(idents) => { + if idents.len() >= 2 { + let column = idents.last().expect("checked length"); + let qualifier = idents.get(idents.len() - 2).map(|ident| ident.value.clone()); + self.push_column(qualifier, column); + } + } + Expr::CompoundFieldAccess { root, .. } | Expr::JsonAccess { value: root, .. } => self.visit_expr(root), + Expr::IsFalse(expr) + | Expr::IsNotFalse(expr) + | Expr::IsTrue(expr) + | Expr::IsNotTrue(expr) + | Expr::IsNull(expr) + | Expr::IsNotNull(expr) + | Expr::IsUnknown(expr) + | Expr::IsNotUnknown(expr) + | Expr::UnaryOp { expr, .. } + | Expr::Nested(expr) => self.visit_expr(expr), + Expr::IsDistinctFrom(left, right) + | Expr::IsNotDistinctFrom(left, right) + | Expr::BinaryOp { left, right, .. } + | Expr::AnyOp { left, right, .. } + | Expr::AllOp { left, right, .. } => { + self.visit_expr(left); + self.visit_expr(right); + } + Expr::InList { expr, list, .. } => { + self.visit_expr(expr); + for item in list { + self.visit_expr(item); + } + } + Expr::InSubquery { expr, subquery, .. } => { + self.visit_expr(expr); + self.visit_query(subquery); + } + Expr::InUnnest { expr, array_expr, .. } => { + self.visit_expr(expr); + self.visit_expr(array_expr); + } + Expr::Between { expr, low, high, .. } => { + self.visit_expr(expr); + self.visit_expr(low); + self.visit_expr(high); + } + Expr::Like { expr, pattern, .. } + | Expr::ILike { expr, pattern, .. } + | Expr::SimilarTo { expr, pattern, .. } + | Expr::RLike { expr, pattern, .. } => { + self.visit_expr(expr); + self.visit_expr(pattern); + } + Expr::Cast { expr, .. } + | Expr::Extract { expr, .. } + | Expr::Ceil { expr, .. } + | Expr::Floor { expr, .. } => self.visit_expr(expr), + Expr::AtTimeZone { timestamp, time_zone } => { + self.visit_expr(timestamp); + self.visit_expr(time_zone); + } + Expr::Position { expr, r#in } => { + self.visit_expr(expr); + self.visit_expr(r#in); + } + Expr::Function(function) => { + self.visit_function_args(&function.parameters); + self.visit_function_args(&function.args); + if let Some(filter) = &function.filter { + self.visit_expr(filter); + } + for order in &function.within_group { + self.visit_expr(&order.expr); + } + } + Expr::Case { operand, conditions, else_result, .. } => { + if let Some(operand) = operand { + self.visit_expr(operand); + } + for condition in conditions { + self.visit_expr(&condition.condition); + self.visit_expr(&condition.result); + } + if let Some(else_result) = else_result { + self.visit_expr(else_result); + } + } + Expr::Subquery(query) | Expr::Exists { subquery: query, .. } => self.visit_query(query), + _ => {} + } + } + + fn visit_function_args(&mut self, args: &FunctionArguments) { + match args { + FunctionArguments::Subquery(query) => self.visit_query(query), + FunctionArguments::List(list) => { + for arg in &list.args { + self.visit_function_arg(arg); + } + for clause in &list.clauses { + if let sqlparser::ast::FunctionArgumentClause::OrderBy(items) = clause { + for item in items { + self.visit_expr(&item.expr); + } + } + } + } + FunctionArguments::None => {} + } + } + + fn visit_function_arg(&mut self, arg: &FunctionArg) { + match arg { + FunctionArg::Named { arg, .. } | FunctionArg::ExprNamed { arg, .. } | FunctionArg::Unnamed(arg) => { + if let FunctionArgExpr::Expr(expr) = arg { + self.visit_expr(expr); + } + } + } + } + + fn push_column(&mut self, qualifier: Option, ident: &Ident) { + self.columns.push(SqlColumnReference { name: ident.value.clone(), qualifier, span: ident.span.into() }); + } +} + +fn table_reference_from_name(name: &ObjectName, alias: Option) -> Option { + let parts: Vec<&Ident> = name.0.iter().filter_map(ObjectNamePart::as_ident).collect(); + let table = parts.last()?; + let schema = parts.get(parts.len().saturating_sub(2)).map(|ident| ident.value.clone()); + + Some(SqlTableReference { name: table.value.clone(), schema, alias, span: table.span.into() }) +} + +fn object_name_last_ident(name: &ObjectName) -> Option<&Ident> { + name.0.iter().rev().find_map(ObjectNamePart::as_ident) +} diff --git a/crates/dbx-core/tests/sql_analysis.rs b/crates/dbx-core/tests/sql_analysis.rs new file mode 100644 index 0000000000..d9ec95ccb5 --- /dev/null +++ b/crates/dbx-core/tests/sql_analysis.rs @@ -0,0 +1,23 @@ +use dbx_core::sql_analysis::analyze_sql_references; + +#[test] +fn extracts_tables_aliases_and_qualified_columns() { + let analysis = analyze_sql_references("select u.missing from users u where u.id = 1", Some("postgres")).unwrap(); + + assert_eq!(analysis.tables.len(), 1); + assert_eq!(analysis.tables[0].name, "users"); + assert_eq!(analysis.tables[0].alias.as_deref(), Some("u")); + + let columns: Vec<_> = + analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect(); + assert_eq!(columns, vec![(Some("u"), "missing"), (Some("u"), "id")]); +} + +#[test] +fn extracts_unqualified_columns_from_single_table_select() { + let analysis = analyze_sql_references("select missing, id from users", Some("postgres")).unwrap(); + + let columns: Vec<_> = + analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect(); + assert_eq!(columns, vec![(None, "missing"), (None, "id")]); +} diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index f59aee0f0d..7e6e688646 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -123,6 +123,7 @@ async fn main() { .route("/query/execute-batch", post(routes::query::execute_batch)) .route("/query/execute-script", post(routes::query::execute_script)) .route("/query/execute-in-transaction", post(routes::query::execute_in_transaction)) + .route("/query/analyze-sql-references", post(routes::query::analyze_sql_references)) .route("/query/cancel", post(routes::query::cancel_query)) .route("/query/close-session", post(routes::query::close_query_session)) // Redis diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index dd728c2a72..73b2e6fb58 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -44,6 +44,13 @@ pub struct ExecuteBatchRequest { pub schema: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyzeSqlReferencesRequest { + pub sql: String, + pub dialect: Option, +} + pub async fn execute_query( State(state): State>, Json(req): Json, @@ -174,3 +181,9 @@ pub async fn execute_in_transaction( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } + +pub async fn analyze_sql_references( + Json(req): Json, +) -> Result, AppError> { + dbx_core::sql_analysis::analyze_sql_references(&req.sql, req.dialect.as_deref()).map(Json).map_err(AppError) +} diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 2f83b3996b..e32fabebfe 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildSqlCompletionItems, getSqlFunctionSignatureHelp, + getSqlCompletionResultValidFor, shouldAutoOpenSqlCompletion, type SqlCompletionColumn, type SqlCompletionTable, @@ -261,6 +262,47 @@ test("auto-opens completion after word characters and explicit dot qualifiers", } }); +test("auto-opens table completion immediately after FROM context whitespace", () => { + for (const sql of ["select * from ", "select * from users join ", "select * from users, "]) { + assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true, sql); + } +}); + +test("suggests table names for empty FROM context prefix", () => { + const items = buildSqlCompletionItems("select * from ", "select * from ".length, { + tables, + columnsByTable, + }); + + assert.deepEqual( + items.slice(0, 4).map((item) => [item.label, item.type]), + [ + ["users", "table"], + ["user_profiles", "table"], + ["orders", "table"], + ["ticket_summary", "table"], + ], + ); +}); + +test("suggests matching table names for partial table input", () => { + const items = buildSqlCompletionItems("select * from ihli", "select * from ihli".length, { + tables: [{ name: "ihli_data", schema: "public", type: "table" }], + columnsByTable, + }); + + assert.deepEqual( + items.map((item) => [item.label, item.type, item.detail]), + [["ihli_data", "table", "public.ihli_data"]], + ); +}); + +test("does not reuse table completion results across typed prefixes", () => { + const validFor = getSqlCompletionResultValidFor("select * from ", "select * from ".length); + + assert.equal(validFor, undefined); +}); + test("auto-opens completion after ON whitespace for join conditions", () => { const sql = "select * from public.users u join public.orders o on "; diff --git a/packages/app-tests/sqlSemanticDiagnostics.test.ts b/packages/app-tests/sqlSemanticDiagnostics.test.ts new file mode 100644 index 0000000000..787c8b8baa --- /dev/null +++ b/packages/app-tests/sqlSemanticDiagnostics.test.ts @@ -0,0 +1,95 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + buildSqlParserErrorDiagnostic, + buildSqlSemanticDiagnostics, + areSqlSemanticDiagnosticsEqual, + shouldRunSqlSemanticDiagnostics, +} from "../../apps/desktop/src/lib/sqlSemanticDiagnostics.ts"; +import type { SqlReferenceAnalysis } from "../../apps/desktop/src/types/database.ts"; + +const span = (startColumn: number, endColumn: number) => ({ + start_line: 1, + start_column: startColumn, + end_line: 1, + end_column: endColumn, +}); + +test("flags missing qualified columns against the referenced table", () => { + const analysis: SqlReferenceAnalysis = { + tables: [{ name: "users", alias: "u", span: span(23, 27) }], + columns: [ + { name: "missing", qualifier: "u", span: span(10, 16) }, + { name: "id", qualifier: "u", span: span(34, 35) }, + ], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [{ name: "users", type: "table" }], + columnsByTable: new Map([["users", [{ name: "id", table: "users" }]]]), + }); + + assert.deepEqual( + diagnostics.map((diagnostic) => diagnostic.message), + ["Unknown column u.missing"], + ); +}); + +test("does not flag unqualified columns when multiple tables make ownership ambiguous", () => { + const analysis: SqlReferenceAnalysis = { + tables: [ + { name: "users", alias: "u", span: span(25, 29) }, + { name: "orders", alias: "o", span: span(35, 40) }, + ], + columns: [{ name: "id", span: span(8, 9) }], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [ + { name: "users", type: "table" }, + { name: "orders", type: "table" }, + ], + columnsByTable: new Map(), + }); + + assert.deepEqual(diagnostics, []); +}); + +test("builds a syntax diagnostic from parser errors with line and column", () => { + const diagnostic = buildSqlParserErrorDiagnostic( + "Expected: end of statement, found: FOM at Line: 1, Column: 10", + "SELECT * FOM", + ); + + assert.equal(diagnostic?.message, "Expected: end of statement, found: FOM at Line: 1, Column: 10"); + assert.deepEqual(diagnostic?.span, span(10, 12)); +}); + +test("compares diagnostics by severity message and span", () => { + const diagnostics = [ + { + message: "Unknown column u.missing", + severity: "warning" as const, + span: span(10, 16), + }, + ]; + + assert.equal( + areSqlSemanticDiagnosticsEqual( + diagnostics, + diagnostics.map((item) => ({ ...item })), + ), + true, + ); + assert.equal( + areSqlSemanticDiagnosticsEqual(diagnostics, [{ ...diagnostics[0], message: "Unknown column u.name" }]), + false, + ); +}); + +test("defers diagnostics while the cursor is in table completion context", () => { + assert.equal(shouldRunSqlSemanticDiagnostics("select * from ", "select * from ".length), false); + assert.equal(shouldRunSqlSemanticDiagnostics("select * from us", "select * from us".length), false); + assert.equal(shouldRunSqlSemanticDiagnostics("select u.", "select u.".length), false); + assert.equal(shouldRunSqlSemanticDiagnostics("select * from users where missing = 1", 42), true); +}); diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index fdbd2d9956..d0dab14f60 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -146,3 +146,11 @@ pub async fn execute_in_transaction( ) .await } + +#[tauri::command] +pub async fn analyze_sql_references( + sql: String, + dialect: Option, +) -> Result { + dbx_core::sql_analysis::analyze_sql_references(&sql, dialect.as_deref()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f1cd292c3a..a9560e6728 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -250,6 +250,8 @@ pub fn run() { commands::ai::delete_ai_conversation, commands::app_settings::load_desktop_settings, commands::app_settings::save_desktop_settings, + commands::app_settings::load_pinned_tree_node_ids, + commands::app_settings::save_pinned_tree_node_ids, commands::connection::test_connection, commands::connection::connect_db, commands::connection::disconnect_db, @@ -285,6 +287,7 @@ pub fn run() { commands::query::execute_batch, commands::query::execute_script, commands::query::execute_in_transaction, + commands::query::analyze_sql_references, commands::sql_file::preview_sql_file, commands::sql_file::execute_sql_file, commands::sql_file::cancel_sql_file_execution,