From 79f8c7284a0d3e0c26d9f30ee15f7bc00f48ee02 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 20 Jul 2026 23:29:18 +0800 Subject: [PATCH] fix(sql): support Oracle system value completion --- .../src/lib/sql/semantic/diagnostics.ts | 13 ++- apps/desktop/src/lib/sql/sqlCompletion.ts | 24 ++++++ packages/app-tests/oracleSystemValues.test.ts | 81 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/app-tests/oracleSystemValues.test.ts diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts index 691a9ea39..f987bbce3 100644 --- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts +++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts @@ -1,5 +1,5 @@ import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; -import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion"; +import { getSqlCompletionContext, isOracleSystemValueName } from "@/lib/sql/sqlCompletion"; import { executableStatementRanges, isOraclePlSqlStatement, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import type { DatabaseType, SqlColumnReference, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database"; @@ -63,6 +63,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche } for (const column of analysis.columns) { + if (isUnquotedOracleSystemValueReference(column, schema)) continue; const table = resolveColumnTable(column, tables, knownTables, schema.sql, scopesById); if (!table) continue; if (schema.missingTables?.has(tableReferenceKey(table))) continue; @@ -84,6 +85,16 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche return diagnostics; } +function isUnquotedOracleSystemValueReference(column: SqlColumnReference, schema: SqlSemanticDiagnosticSchema): boolean { + if (column.qualifier || !isOracleSystemValueName(column.name, schema.databaseType)) return false; + if (!schema.sql) return false; + + const range = sqlTextSpanToOffsetRange(schema.sql, column.span); + if (!range) return false; + const firstCharacter = schema.sql.slice(range.from, range.to).trimStart()[0]; + return firstCharacter !== '"' && firstCharacter !== "'" && firstCharacter !== "`" && firstCharacter !== "["; +} + export function isSqlVirtualTableReference(table: { name: string; schema?: string | null }, databaseType?: DatabaseType): boolean { return databaseType === "mysql" && !table.schema && normalizeName(table.name) === "dual"; } diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 8dc3dd48c..7021cd176 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -509,6 +509,14 @@ const ORACLE_SQL_TYPES = [ "XMLTYPE", ]; +const ORACLE_SYSTEM_VALUE_NAMES = ["SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", "LOCALTIMESTAMP", "SESSIONTIMEZONE", "DBTIMEZONE", "USER", "UID"] as const; + +const ORACLE_SYSTEM_VALUE_NAME_SET = new Set(ORACLE_SYSTEM_VALUE_NAMES); + +export function isOracleSystemValueName(name: string, databaseType?: DatabaseType): boolean { + return isOracleLikeDatabase(databaseType) && ORACLE_SYSTEM_VALUE_NAME_SET.has(name.toUpperCase()); +} + const NON_ORACLE_COMPLETION_WORDS = new Set(["BIGSERIAL", "BOOLEAN", "ELSEIF", "LIMIT", "LOCALTIME", "SERIAL", "STRING", "TEXT", "TIME", "USE"]); const ORACLE_SQL_KEYWORDS = Array.from( @@ -1350,6 +1358,9 @@ class SqlCompletionProvider { if (!preferReferencedColumns || context.suggestRoutines) { const functionItems = buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType); this.items.push(...(preferReferencedColumns ? functionItems.filter((item) => item.label.toLowerCase().startsWith(context.prefix.toLowerCase())) : functionItems)); + if (isOracleLikeDatabase(this.databaseType)) { + this.items.push(...buildOracleSystemValueItems(context.prefix, this.input.keywordCase)); + } } } @@ -4025,6 +4036,19 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map matchesPrefix(name, prefix)).map((name) => { + const label = applySqlKeywordCase(name, keywordCase); + return { + label, + type: "function" as const, + detail: "Oracle system value", + apply: label, + boost: computeBoost(name, prefix) + 300, + }; + }); +} + function mongoCompletionItemToSqlCompletionItem(item: MongoCompletionItem): SqlCompletionItem { return { label: item.label, diff --git a/packages/app-tests/oracleSystemValues.test.ts b/packages/app-tests/oracleSystemValues.test.ts new file mode 100644 index 000000000..743c9ed43 --- /dev/null +++ b/packages/app-tests/oracleSystemValues.test.ts @@ -0,0 +1,81 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { buildSqlCompletionItems } from "../../apps/desktop/src/lib/sql/sqlCompletion.ts"; +import { buildSqlSemanticDiagnostics } from "../../apps/desktop/src/lib/sql/semantic/diagnostics.ts"; +import type { SqlReferenceAnalysis } from "../../apps/desktop/src/types/database.ts"; + +const ORACLE_SYSTEM_VALUES = ["SYSDATE", "SYSTIMESTAMP", "CURRENT_DATE", "CURRENT_TIMESTAMP", "LOCALTIMESTAMP", "SESSIONTIMEZONE", "DBTIMEZONE", "USER", "UID"]; + +const span = (startColumn: number, endColumn: number) => ({ + start_line: 1, + start_column: startColumn, + end_line: 1, + end_column: endColumn, +}); + +test("suggests Oracle system values without function-call parentheses", () => { + for (const name of ORACLE_SYSTEM_VALUES) { + const prefix = name.slice(0, Math.min(5, name.length)); + const sql = `SELECT * FROM orders WHERE created_at > ${prefix}`; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", type: "table" }], + columnsByTable: new Map([["orders", [{ name: "created_at", table: "orders" }]]]), + databaseType: "oracle", + }); + + const systemValue = items.find((item) => item.label === name); + assert.equal(systemValue?.type, "function", name); + assert.equal(systemValue?.apply, name, name); + assert.equal(systemValue?.detail, "Oracle system value", name); + } +}); + +test("does not flag unquoted Oracle system values as table columns", () => { + for (const name of ORACLE_SYSTEM_VALUES) { + const sql = `SELECT id FROM orders WHERE ${name} IS NOT NULL`; + const startColumn = sql.indexOf(name) + 1; + const diagnostics = buildSqlSemanticDiagnostics( + { + tables: [{ name: "orders", span: span(16, 21), scope_id: 0 }], + columns: [ + { name: "id", span: span(8, 9), scope_id: 0 }, + { name, span: span(startColumn, startColumn + name.length - 1), scope_id: 0 }, + ], + scopes: [{ id: 0, parent_id: null }], + }, + { + tables: [{ name: "orders", type: "table" }], + columnsByTable: new Map([["orders", [{ name: "id", table: "orders" }]]]), + databaseType: "oracle", + sql, + }, + ); + assert.deepEqual(diagnostics, [], name); + } +}); + +test("continues validating qualified and quoted Oracle system-value names as columns", () => { + const sql = 'SELECT o.id FROM orders o WHERE missing > 0 AND o.SYSDATE > 0 AND "SYSDATE" > 0'; + const analysis: SqlReferenceAnalysis = { + tables: [{ name: "orders", alias: "o", span: span(18, 23), scope_id: 0 }], + columns: [ + { name: "id", qualifier: "o", span: span(10, 11), scope_id: 0 }, + { name: "missing", span: span(33, 39), scope_id: 0 }, + { name: "SYSDATE", qualifier: "o", span: span(51, 57), scope_id: 0 }, + { name: "SYSDATE", span: span(67, 75), scope_id: 0 }, + ], + scopes: [{ id: 0, parent_id: null }], + }; + + const diagnostics = buildSqlSemanticDiagnostics(analysis, { + tables: [{ name: "orders", type: "table" }], + columnsByTable: new Map([["orders", [{ name: "id", table: "orders" }]]]), + databaseType: "oracle", + sql, + }); + + assert.deepEqual( + diagnostics.map((diagnostic) => diagnostic.message), + ["Unknown column missing", "Unknown column o.SYSDATE", "Unknown column SYSDATE"], + ); +});