diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index ae1de8152..1583806b5 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -465,6 +465,74 @@ const SQLSERVER_SQL_KEYWORDS = [ "SHOWPLAN_XML", ]; +function sqlDialectCompletionWords(...sources: Array): string[] { + return sources + .flatMap((source) => (source ?? "").split(/\s+/)) + .filter((keyword) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(keyword)) + .map((keyword) => keyword.toUpperCase()); +} + +const ORACLE_SQL_TYPES = [ + "BFILE", + "BINARY_DOUBLE", + "BINARY_FLOAT", + "BLOB", + "CHAR", + "CLOB", + "DATE", + "DEC", + "DECIMAL", + "DOUBLE PRECISION", + "FLOAT", + "INT", + "INTEGER", + "INTERVAL DAY TO SECOND", + "INTERVAL YEAR TO MONTH", + "LONG", + "LONG RAW", + "NCHAR", + "NCLOB", + "NUMBER", + "NUMERIC", + "NVARCHAR2", + "RAW", + "REAL", + "ROWID", + "SMALLINT", + "TIMESTAMP", + "TIMESTAMP WITH LOCAL TIME ZONE", + "TIMESTAMP WITH TIME ZONE", + "UROWID", + "VARCHAR", + "VARCHAR2", + "XMLTYPE", +]; + +const NON_ORACLE_COMPLETION_WORDS = new Set(["BIGSERIAL", "BOOLEAN", "ELSEIF", "LIMIT", "LOCALTIME", "SERIAL", "STRING", "TEXT", "TIME", "USE"]); + +const ORACLE_SQL_KEYWORDS = Array.from( + new Set([ + ...sqlDialectCompletionWords(PLSQL.spec.keywords).filter((keyword) => !NON_ORACLE_COMPLETION_WORDS.has(keyword)), + ...ORACLE_SQL_TYPES, + "BULK COLLECT", + "CONNECT BY", + "DATABASE LINK", + "EXECUTE IMMEDIATE", + "FLASHBACK", + "FOR UPDATE", + "MATERIALIZED VIEW", + "MERGE", + "ORDER SIBLINGS BY", + "OR REPLACE", + "PACKAGE BODY", + "PURGE", + "RETURNING INTO", + "SEQUENCE", + "START WITH", + "TYPE BODY", + ]), +); + const DATABASE_SQL_KEYWORDS: Partial> = { mysql: MYSQL_SQL_KEYWORDS, postgres: POSTGRES_SQL_KEYWORDS, @@ -473,6 +541,8 @@ const DATABASE_SQL_KEYWORDS: Partial> = { turso: SQLITE_SQL_KEYWORDS, "cloudflare-d1": SQLITE_SQL_KEYWORDS, sqlserver: SQLSERVER_SQL_KEYWORDS, + oracle: ORACLE_SQL_KEYWORDS, + "oceanbase-oracle": ORACLE_SQL_KEYWORDS, manticoresearch: MANTICORESEARCH_SQL_KEYWORDS, }; @@ -614,6 +684,7 @@ const DATA_TYPE_KEYWORDS = new Set([ "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", + ...ORACLE_SQL_TYPES, ]); // Window functions that should use OVER() completion @@ -3826,7 +3897,8 @@ function buildNonAggregatedColumnItems(context: SqlCompletionContext, columnsByT function activeSqlKeywords(databaseType?: DatabaseType): string[] { if (databaseType === "mongodb") return []; const databaseKeywords = databaseType ? DATABASE_SQL_KEYWORDS[databaseType] : undefined; - return databaseType ? Array.from(new Set([...COMMON_SQL_KEYWORDS, ...(databaseKeywords ?? [])])) : Array.from(new Set(SQL_KEYWORDS)); + const keywords = databaseType ? Array.from(new Set([...COMMON_SQL_KEYWORDS, ...(databaseKeywords ?? [])])) : Array.from(new Set(SQL_KEYWORDS)); + return isOracleLikeDatabase(databaseType) ? keywords.filter((keyword) => !NON_ORACLE_COMPLETION_WORDS.has(keyword)) : keywords; } function isOracleLikeDatabase(databaseType?: DatabaseType): boolean { @@ -3854,11 +3926,11 @@ function isPendingJoinKeywordContext(context: SqlCompletionContext): boolean { function buildKeywordItems(prefix: string, context: SqlCompletionContext, databaseType?: DatabaseType, keywordCase?: SqlKeywordCase): SqlCompletionItem[] { const isDml = context.statementKind === "select" || context.statementKind === "insert" || context.statementKind === "update" || context.statementKind === "delete"; const showDdl = !isDml || context.suggestTables; + const functionSignatures = activeFunctionSignatures(databaseType); return activeSqlKeywords(databaseType) .filter((keyword) => { - if (SQL_FUNCTION_SIGNATURES.has(keyword)) return false; - if (databaseType && DATABASE_FUNCTION_SIGNATURES[databaseType]?.has(keyword)) return false; + if (functionSignatures.has(keyword)) return false; if (WINDOW_FUNCTIONS.has(keyword)) return false; if (!matchesPrefix(keyword, prefix)) return false; if (!showDdl && isDml && (DDL_ONLY_KEYWORDS.has(keyword) || DATA_TYPE_KEYWORDS.has(keyword))) return false; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index f479f48a4..727383dda 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -144,6 +144,86 @@ test("suggests PostgreSQL-specific data types and functions", () => { ); }); +test("suggests Oracle SQL, PL/SQL, and data type keywords", () => { + const keywordCases = [ + ["tru", "TRUNCATE"], + ["mer", "MERGE"], + ["dec", "DECLARE"], + ["els", "ELSIF"], + ["pac", "PACKAGE"], + ["seq", "SEQUENCE"], + ["flash", "FLASHBACK"], + ["mat", "MATERIALIZED VIEW"], + ] as const; + + for (const [prefix, expected] of keywordCases) { + const items = buildSqlCompletionItems(prefix, prefix.length, { + tables: [], + columnsByTable: new Map(), + databaseType: "oracle", + }); + assert.ok( + items.some((item) => item.type === "keyword" && item.label === expected), + `${expected} should be suggested for ${prefix}`, + ); + } + + const typeSql = "CREATE TABLE events (payload varc"; + const typeItems = buildSqlCompletionItems(typeSql, typeSql.length, { + tables: [], + columnsByTable: new Map(), + databaseType: "oracle", + }); + assert.ok(typeItems.some((item) => item.type === "keyword" && item.label === "VARCHAR2")); +}); + +test("does not suggest cross-dialect words for Oracle", () => { + const unsupportedWords = ["LIMIT", "LOCALTIME", "USE", "ELSEIF", "SERIAL", "BIGSERIAL", "TEXT", "BOOLEAN", "STRING", "TIME"]; + + for (const unsupportedWord of unsupportedWords) { + const prefix = unsupportedWord.toLowerCase(); + const items = buildSqlCompletionItems(prefix, prefix.length, { + tables: [], + columnsByTable: new Map(), + databaseType: "oracle", + }); + assert.equal( + items.some((item) => item.type === "keyword" && item.label === unsupportedWord), + false, + `${unsupportedWord} should not be suggested for Oracle`, + ); + } + + for (const supportedWord of ["LOCALTIMESTAMP", "NUMBER", "VARCHAR2", "XMLTYPE"]) { + const prefix = supportedWord.toLowerCase(); + const items = buildSqlCompletionItems(prefix, prefix.length, { + tables: [], + columnsByTable: new Map(), + databaseType: "oracle", + }); + assert.ok( + items.some((item) => item.type === "keyword" && item.label === supportedWord), + `${supportedWord} should be suggested for Oracle`, + ); + } +}); + +test("keeps TRUNCATE as a statement keyword for Oracle-compatible databases", () => { + for (const databaseType of ["oracle", "oceanbase-oracle"] as const) { + const items = buildSqlCompletionItems("tru", 3, { + tables: [], + columnsByTable: new Map(), + databaseType, + }); + + assert.ok(items.some((item) => item.type === "keyword" && item.label === "TRUNCATE")); + assert.equal( + items.some((item) => item.type === "function" && item.label === "TRUNCATE"), + false, + ); + } +}); + test("suggests Manticore Search SQL functions and command snippets", () => { const matchItems = buildSqlCompletionItems("select * from products where mat", "select * from products where mat".length, { tables,