diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index db6880738..e725bf9ba 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -933,7 +933,7 @@ export interface SqlCompletionTable { export interface SqlCompletionObject { name: string; schema?: string; - type: "procedure" | "function" | "trigger"; + type: "procedure" | "function" | "trigger" | "package"; parentSchema?: string; parentName?: string; } @@ -995,6 +995,10 @@ export interface SqlCompletionContext { nonAggregatedSelectColumns: string[]; comparisonLeftColumn?: string; onStar: boolean; + preferredKeywords: string[]; + updateTarget?: { table: string; schema?: string }; + deleteTarget?: { table: string; schema?: string }; + oracleTableFunctionContext?: boolean; } export interface SqlFunctionSignatureHelp { @@ -1015,6 +1019,18 @@ export interface SqlCompletionTranslations { functionDescriptions: Record; } +export interface SqlCompletionProviderInput { + tables: SqlCompletionTable[]; + objects?: SqlCompletionObject[]; + columnsByTable: Map; + foreignKeysByTable?: Map; + schemas?: string[]; + translations?: SqlCompletionTranslations; + snippets?: SqlSnippet[]; + dialect?: "mysql" | "postgres" | "sqlserver"; + databaseType?: DatabaseType; +} + export function buildSqlCompletionItems( sql: string, cursor: number, @@ -1033,82 +1049,91 @@ export function buildSqlCompletionItems( return buildSqlCompletionItemsFromContext(context, input); } -export function buildSqlCompletionItemsFromContext( - context: SqlCompletionContext, - input: { - tables: SqlCompletionTable[]; - objects?: SqlCompletionObject[]; - columnsByTable: Map; - foreignKeysByTable?: Map; - schemas?: string[]; - translations?: SqlCompletionTranslations; - snippets?: SqlSnippet[]; - dialect?: "mysql" | "postgres" | "sqlserver"; - databaseType?: DatabaseType; - }, -): SqlCompletionItem[] { - const items: SqlCompletionItem[] = []; - const t = input.translations; - const dialect = input.dialect; - const databaseType = input.databaseType; +export function buildSqlCompletionItemsFromContext(context: SqlCompletionContext, input: SqlCompletionProviderInput): SqlCompletionItem[] { + return new SqlCompletionProvider(context, input).build(); +} - if (databaseType === "mongodb") { - return dedupeAndSort(buildMongoCompletionItems(context.prefix)); +class SqlCompletionProvider { + private readonly items: SqlCompletionItem[] = []; + private readonly t?: SqlCompletionTranslations; + private readonly dialect?: "mysql" | "postgres" | "sqlserver"; + private readonly databaseType?: DatabaseType; + + constructor( + private readonly context: SqlCompletionContext, + private readonly input: SqlCompletionProviderInput, + ) { + this.t = input.translations; + this.dialect = input.dialect; + this.databaseType = input.databaseType; } - if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) { - items.push(...buildSnippetItems(context.prefix, input.snippets ?? DEFAULT_SQL_SNIPPETS)); - items.push(...buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(t), databaseType)); - } + build(): SqlCompletionItem[] { + const { context } = this; - if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.prioritizeSelectAliases) { - items.push(...buildSelectAliasItems(context)); - } - - if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.isGroupBy && context.nonAggregatedSelectColumns.length > 0) { - items.push(...buildNonAggregatedColumnItems(context, input.columnsByTable, dialect)); - } - - if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.suggestJoinConditions) { - items.push(...buildJoinConditionItems(context, input.columnsByTable, input.foreignKeysByTable, dialect)); - } - - if (context.suggestKeywords && !context.exclusiveRoutineSuggestions) { - items.push(...buildKeywordItems(context.prefix, context, databaseType)); - } - - if (!context.exclusiveTableSuggestions && context.suggestColumns) { - items.push(...buildColumnItems(context, input.columnsByTable, dialect)); - } - - // Suggest aliases for referenced tables (independent of table-suggestion mode) - if (context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) { - items.push(...buildAliasItems(context)); - } - - if (!context.exclusiveColumnSuggestions && context.suggestTables) { - items.push(...buildTableItems(context.prefix, input.tables, dialect)); - if (input.schemas && input.schemas.length > 0) { - items.push(...buildSchemaItems(context.prefix, input.schemas, dialect)); + if (this.databaseType === "mongodb") { + return dedupeAndSort(buildMongoCompletionItems(context.prefix)); } - } - if (context.suggestRoutines || context.exclusiveRoutineSuggestions) { - items.push(...buildObjectItems(context, input.objects ?? [], dialect)); - } + if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) { + this.items.push(...buildSnippetItems(context.prefix, this.input.snippets ?? DEFAULT_SQL_SNIPPETS)); + this.items.push(...buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType)); + } - // Type-aware value hints after comparison operator - if (context.comparisonLeftColumn && context.suggestKeywords) { - items.push(...buildComparisonValueItems(context, input.columnsByTable, t)); - } + if (context.preferredKeywords.length > 0) { + this.items.push(...buildPreferredKeywordItems(context.prefix, context.preferredKeywords)); + } - // SELECT * expansion - if (context.onStar) { - const starItem = buildStarExpansionItem(input.columnsByTable, t, dialect); - if (starItem) items.push(starItem); - } + if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.prioritizeSelectAliases) { + this.items.push(...buildSelectAliasItems(context)); + } - return dedupeAndSort(items); + if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.isGroupBy && context.nonAggregatedSelectColumns.length > 0) { + this.items.push(...buildNonAggregatedColumnItems(context, this.input.columnsByTable, this.dialect)); + } + + if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions && context.suggestJoinConditions) { + this.items.push(...buildJoinConditionItems(context, this.input.columnsByTable, this.input.foreignKeysByTable, this.dialect)); + } + + if (context.suggestKeywords && !context.exclusiveRoutineSuggestions) { + this.items.push(...buildKeywordItems(context.prefix, context, this.databaseType)); + } + + if (!context.exclusiveTableSuggestions && context.suggestColumns) { + this.items.push(...buildColumnItems(context, this.input.columnsByTable, this.dialect)); + } + + if (context.referencedTables.length > 0 && !context.suggestColumns && !context.insertTable) { + this.items.push(...buildAliasItems(context)); + } + + if (!context.exclusiveColumnSuggestions && context.suggestTables) { + this.items.push(...buildForeignKeyRelatedTableItems(context, this.input.tables, this.input.foreignKeysByTable, this.dialect)); + this.items.push(...buildTableItems(context.prefix, this.input.tables, this.dialect)); + if (isOracleLikeDatabase(this.databaseType)) { + this.items.push(...buildOracleTableFunctionItems(context.prefix)); + } + if (this.input.schemas && this.input.schemas.length > 0) { + this.items.push(...buildSchemaItems(context.prefix, this.input.schemas, this.dialect)); + } + } + + if (context.suggestRoutines || context.exclusiveRoutineSuggestions || context.oracleTableFunctionContext) { + this.items.push(...buildObjectItems(context, this.input.objects ?? [], this.dialect)); + } + + if (context.comparisonLeftColumn && context.suggestKeywords) { + this.items.push(...buildComparisonValueItems(context, this.input.columnsByTable, this.t)); + } + + if (context.onStar) { + const starItem = buildStarExpansionItem(this.input.columnsByTable, this.t, this.dialect); + if (starItem) this.items.push(starItem); + } + + return dedupeAndSort(this.items); + } } export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolean { @@ -1403,6 +1428,9 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet // Detect INSERT INTO table (column list) context const insertInfo = detectInsertColumnListContext(beforeCursor); + const updateInfo = detectUpdateCompletionContext(beforeCursor); + const deleteInfo = detectDeleteCompletionContext(beforeCursor); + const oracleTableFunctionContext = detectOracleTableFunctionContext(beforeCursor); const afterTableTrigger = TABLE_TRIGGER_KEYWORDS.has(lastWord) || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken); const exclusiveTableSuggestions = EXCLUSIVE_TABLE_TRIGGER_KEYWORDS.has(lastWord) || (JOIN_MODIFIERS.has(lastWord) && isFollowedByJoin(beforeToken)) || isInTableListContext(beforeToken); @@ -1413,19 +1441,21 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet const inJoinConditionContext = isInJoinConditionContext(beforeCursor); const prioritizeSelectAliases = isInOrderOrGroupByContext(beforeCursor); const inCallRoutineContext = isCallRoutineContext(beforeCursor); + const inPotentialPackageMemberContext = !!qualifier && !exclusiveTableSuggestions && !insertInfo && !oracleTableFunctionContext; const statementKind = detectStatementKind(beforeCursor || fullStatement); + const preferredKeywords = preferredKeywordsForCompletion(updateInfo, deleteInfo); return { prefix, qualifier: insertInfo ? undefined : qualifier, suggestTables: insertInfo ? false : afterTableTrigger, - suggestColumns: !!qualifier || (inColumnContext && referencedTables.length > 0), + suggestColumns: !!qualifier || !!updateInfo?.inSetClause || (inColumnContext && referencedTables.length > 0), suggestKeywords: !exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && !inCallRoutineContext, - suggestRoutines: inCallRoutineContext || (!exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2), + suggestRoutines: inCallRoutineContext || oracleTableFunctionContext || inPotentialPackageMemberContext || (!exclusiveTableSuggestions && !exclusiveColumnSuggestions && !insertInfo && prefix.length >= 2), suggestJoinConditions: insertInfo ? false : inJoinConditionContext && referencedTables.length >= 2, exclusiveTableSuggestions: insertInfo ? false : exclusiveTableSuggestions, - exclusiveColumnSuggestions: exclusiveColumnSuggestions || !!insertInfo, + exclusiveColumnSuggestions: exclusiveColumnSuggestions || !!insertInfo || !!updateInfo?.inSetClause, exclusiveRoutineSuggestions: inCallRoutineContext, prioritizeSelectAliases: insertInfo ? false : prioritizeSelectAliases, selectAliases: prioritizeSelectAliases ? extractSelectAliases(fullStatement) : [], @@ -1438,6 +1468,10 @@ export function getSqlCompletionContext(sql: string, cursor: number): SqlComplet nonAggregatedSelectColumns: extractNonAggregatedSelectColumns(fullStatement), comparisonLeftColumn: detectComparisonLeftColumn(beforeCursor), onStar: detectOnStar(beforeCursor), + preferredKeywords, + updateTarget: updateInfo?.target, + deleteTarget: deleteInfo?.target, + oracleTableFunctionContext, }; } @@ -1640,6 +1674,46 @@ function detectInsertColumnListContext(beforeCursor: string): { table: string; s return { table: first! }; } +function detectUpdateCompletionContext(beforeCursor: string): { target: { table: string; schema?: string }; afterTarget: boolean; inSetClause: boolean; afterSetAssignments: boolean } | null { + const cleaned = beforeCursor.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""'); + const match = /^\s*update\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/i.exec(cleaned); + if (!match) return null; + const [first, second] = splitQualifiedName(match[1] ?? ""); + if (!first) return null; + const target = second ? { schema: first, table: second } : { table: first }; + const afterTargetText = cleaned.slice(match[0].length).trimStart(); + const afterTarget = !afterTargetText || /^[A-Za-z_][\w$]*$/i.test(afterTargetText); + const setIndex = afterTargetText.search(/\bset\b/i); + if (setIndex < 0) return { target, afterTarget, inSetClause: false, afterSetAssignments: false }; + const setSegment = afterTargetText.slice(setIndex + 3); + const inSetClause = !/\bwhere\b/i.test(setSegment); + const afterSetAssignments = inSetClause && /(?:=|,)\s*(?:''|""|[A-Za-z0-9_.$]+)?\s+[A-Za-z_][\w$]*$/i.test(setSegment); + return { target, afterTarget: false, inSetClause, afterSetAssignments }; +} + +function detectDeleteCompletionContext(beforeCursor: string): { target?: { table: string; schema?: string }; afterTarget: boolean } | null { + const cleaned = beforeCursor.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""'); + const match = /^\s*delete(?:\s+[A-Za-z_][\w$]*)?\s+from\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/i.exec(cleaned); + if (!match) return /^\s*delete\s+(?:from\s+)?[A-Za-z_][\w$]*$/i.test(cleaned) ? { afterTarget: false } : null; + const [first, second] = splitQualifiedName(match[1] ?? ""); + const target = first ? (second ? { schema: first, table: second } : { table: first }) : undefined; + const afterTargetText = cleaned.slice(match[0].length).trimStart(); + return { target, afterTarget: !afterTargetText || /^[A-Za-z_][\w$]*$/i.test(afterTargetText) }; +} + +function detectOracleTableFunctionContext(beforeCursor: string): boolean { + const cleaned = beforeCursor.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""'); + return /\b(?:from|join)\s+table\s*\(\s*(?:(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)\.){0,2}[A-Za-z_][\w$]*$/i.test(cleaned); +} + +function preferredKeywordsForCompletion(updateInfo: ReturnType, deleteInfo: ReturnType): string[] { + const keywords: string[] = []; + if (updateInfo?.afterTarget) keywords.push("SET"); + if (updateInfo?.afterSetAssignments) keywords.push("WHERE"); + if (deleteInfo?.afterTarget) keywords.push("WHERE"); + return keywords; +} + function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { // Keywords that should NOT be treated as table aliases const ALIAS_BLACKLIST = new Set([ @@ -2075,6 +2149,45 @@ function buildTableItems(prefix: string, tables: SqlCompletionTable[], dialect?: .slice(0, MAX_TABLE_COMPLETION_ITEMS); } +function buildForeignKeyRelatedTableItems(context: SqlCompletionContext, tables: SqlCompletionTable[], foreignKeysByTable?: Map, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { + if (!foreignKeysByTable || context.referencedTables.length === 0) return []; + const candidates = new Map(); + for (const ref of context.referencedTables) { + for (const [ownerKey, foreignKeys] of foreignKeysByTable.entries()) { + const ownerName = ownerKey.split(".").filter(Boolean).pop() ?? ownerKey; + for (const foreignKey of foreignKeys) { + if (referencedTableMatchesName(ref, ownerName)) { + const target = findCompletionTable(tables, foreignKey.ref_table, foreignKey.ref_schema); + if (target && matchesPrefix(target.name, context.prefix)) { + candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${foreignKey.column} → ${foreignKey.ref_table}.${foreignKey.ref_column}` }); + } + } else if (referencedTableMatchesName(ref, foreignKey.ref_table, foreignKey.ref_schema)) { + const target = findCompletionTable(tables, ownerName); + if (target && matchesPrefix(target.name, context.prefix)) { + candidates.set(`${target.schema ?? ""}.${target.name}`.toLowerCase(), { table: target, detail: `related by ${ownerName}.${foreignKey.column} → ${foreignKey.ref_column}` }); + } + } + } + } + } + + return [...candidates.values()] + .map(({ table, detail }) => ({ + label: table.name, + type: "table" as const, + detail, + apply: quoteSqlIdentifier(table.name, dialect), + boost: computeBoost(table.name, context.prefix) + 3600, + })) + .sort(compareCompletionItems); +} + +function findCompletionTable(tables: SqlCompletionTable[], name: string, schema?: string | null): SqlCompletionTable | undefined { + const normalizedName = normalizeIdentifierPart(name); + const normalizedSchema = schema ? normalizeIdentifierPart(schema) : undefined; + return tables.find((table) => normalizeIdentifierPart(table.name) === normalizedName && (!normalizedSchema || !table.schema || normalizeIdentifierPart(table.schema) === normalizedSchema)); +} + function buildSchemaItems(prefix: string, schemas: string[], dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { return schemas .filter((schema) => matchesPrefix(schema, prefix)) @@ -2091,23 +2204,78 @@ function buildSchemaItems(prefix: string, schemas: string[], dialect?: "mysql" | function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionObject[], dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { const onlyProcedures = context.exclusiveRoutineSuggestions; return objects - .filter((object) => (!onlyProcedures || object.type === "procedure") && matchesPrefix(object.name, context.prefix)) + .filter((object) => (!onlyProcedures || object.type === "procedure") && objectMatchesCompletionContext(object, context)) .map((object) => { + const qualifiedByContext = objectIsQualifiedByContext(object, context); const applyName = - context.qualifier && object.schema?.toLowerCase() === context.qualifier.toLowerCase() ? quoteSqlIdentifier(object.name, dialect) : object.schema ? `${quoteSqlIdentifier(object.schema, dialect)}.${quoteSqlIdentifier(object.name, dialect)}` : quoteSqlIdentifier(object.name, dialect); - const detail = object.type === "trigger" && object.parentName ? `trigger on ${object.parentName}` : object.schema ? `${object.type} in ${object.schema}` : object.type; + qualifiedByContext || (context.qualifier && object.schema?.toLowerCase() === context.qualifier.toLowerCase()) + ? quoteSqlIdentifier(object.name, dialect) + : object.schema + ? `${quoteSqlIdentifier(object.schema, dialect)}.${quoteSqlIdentifier(object.name, dialect)}` + : quoteSqlIdentifier(object.name, dialect); + const detail = object.type === "trigger" && object.parentName ? `trigger on ${object.parentName}` : object.parentName ? `${object.type} in ${object.parentName}` : object.schema ? `${object.type} in ${object.schema}` : object.type; return { label: object.name, type: "function" as const, detail, - apply: object.type === "trigger" ? applyName : `${applyName}()`, - boost: computeBoost(object.name, context.prefix) + (object.type === "procedure" ? 1800 : 900), + apply: object.type === "trigger" || object.type === "package" ? applyName : `${applyName}()`, + boost: computeBoost(object.name, context.prefix) + (object.type === "procedure" ? 1800 : object.type === "package" ? 1600 : 900), }; }) .sort(compareCompletionItems) .slice(0, MAX_TABLE_COMPLETION_ITEMS); } +function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean { + if (!context.qualifier || !object.parentName) return false; + const qualifier = context.qualifier.toLowerCase(); + const qualifierParts = qualifier.split(".").filter(Boolean); + const qualifierSchema = qualifierParts.length > 1 ? qualifierParts[qualifierParts.length - 2] : undefined; + const qualifierPackage = qualifierParts[qualifierParts.length - 1]; + return object.parentName.toLowerCase() === qualifier || (!!qualifierPackage && object.parentName.toLowerCase() === qualifierPackage && (!qualifierSchema || !object.parentSchema || object.parentSchema.toLowerCase() === qualifierSchema)); +} + +function objectMatchesCompletionContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean { + if (context.oracleTableFunctionContext && object.type !== "function") return false; + if (context.qualifier) { + const qualifier = context.qualifier.toLowerCase(); + const qualifierParts = qualifier.split(".").filter(Boolean); + const qualifierSchema = qualifierParts.length > 1 ? qualifierParts[qualifierParts.length - 2] : undefined; + const qualifierPackage = qualifierParts[qualifierParts.length - 1]; + if (object.parentName && object.parentName.toLowerCase() === qualifier) return matchesPrefix(object.name, context.prefix); + if (object.parentName && qualifierPackage && object.parentName.toLowerCase() === qualifierPackage && (!qualifierSchema || !object.parentSchema || object.parentSchema.toLowerCase() === qualifierSchema)) return matchesPrefix(object.name, context.prefix); + if (object.schema && object.schema.toLowerCase() === qualifier) return matchesPrefix(object.name, context.prefix); + if (object.parentSchema && `${object.parentSchema}.${object.parentName ?? ""}`.toLowerCase() === qualifier) return matchesPrefix(object.name, context.prefix); + } + return matchesPrefix(object.name, context.prefix); +} + +function buildOracleTableFunctionItems(prefix: string): SqlCompletionItem[] { + const items = [ + { label: "TABLE", detail: "Oracle table function", apply: "TABLE(${function_call})" }, + { label: "THE", detail: "Oracle nested-table expression", apply: "THE(${subquery})" }, + { label: "XMLTABLE", detail: "XML to relational rows", apply: "XMLTABLE(${xpath})" }, + { label: "JSON_TABLE", detail: "JSON to relational rows", apply: "JSON_TABLE(${expr}, ${path})" }, + ]; + return items + .filter((item) => matchesPrefix(item.label, prefix)) + .map((item) => ({ + ...item, + type: "function" as const, + boost: computeBoost(item.label, prefix) + 2200, + })); +} + +function buildPreferredKeywordItems(prefix: string, keywords: string[]): SqlCompletionItem[] { + return keywords + .filter((keyword) => matchesPrefix(keyword, prefix)) + .map((keyword, index) => ({ + label: keyword, + type: "keyword" as const, + boost: computeBoost(keyword, prefix) + 6200 - index, + })); +} + function buildStarExpansionItem(columnsByTable: Map, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem | null { const allColumns: string[] = []; const seen = new Set(); @@ -2864,6 +3032,10 @@ function activeSqlKeywords(databaseType?: DatabaseType): string[] { return databaseType ? Array.from(new Set([...COMMON_SQL_KEYWORDS, ...(databaseKeywords ?? [])])) : Array.from(new Set(SQL_KEYWORDS)); } +function isOracleLikeDatabase(databaseType?: DatabaseType): boolean { + return databaseType === "oracle" || databaseType === "oceanbase-oracle"; +} + function buildKeywordItems(prefix: string, context: SqlCompletionContext, databaseType?: DatabaseType): SqlCompletionItem[] { const isDml = context.statementKind === "select" || context.statementKind === "insert" || context.statementKind === "update" || context.statementKind === "delete"; const showDdl = !isDml || context.suggestTables; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 5837b8093..20d0cb3f5 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1907,7 +1907,7 @@ export const useConnectionStore = defineStore("connection", () => { function toSqlCompletionObject(object: ObjectInfo): SqlCompletionObject | null { const objectType = object.object_type.toUpperCase(); - const type = objectType.includes("PROCEDURE") ? "procedure" : objectType.includes("FUNCTION") ? "function" : objectType.includes("TRIGGER") ? "trigger" : null; + const type = objectType.includes("PROCEDURE") ? "procedure" : objectType.includes("FUNCTION") ? "function" : objectType.includes("TRIGGER") ? "trigger" : objectType.includes("PACKAGE") ? "package" : null; if (!type) return null; return { name: object.name, @@ -1919,7 +1919,7 @@ export const useConnectionStore = defineStore("connection", () => { } function fuzzyCompletionObjectMatch(object: SqlCompletionObject, filter: string): boolean { - return fuzzyTextMatch(object.name, filter) || (!!object.schema && fuzzyTextMatch(object.schema, filter)); + return fuzzyTextMatch(object.name, filter) || (!!object.schema && fuzzyTextMatch(object.schema, filter)) || (!!object.parentName && fuzzyTextMatch(object.parentName, filter)) || (!!object.parentSchema && fuzzyTextMatch(`${object.parentSchema}.${object.parentName ?? ""}`, filter)); } function fuzzyTextMatch(value: string, filter: string): boolean { diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index c3be0d2cc..240bc3afa 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -695,6 +695,35 @@ test("suggests user functions and triggers with fuzzy matching", () => { assert.ok(triggerItems.some((item) => item.label === "trg_users_audit" && item.detail === "trigger on users")); }); +test("suggests Oracle table-function helpers in table reference context", () => { + const items = buildSqlCompletionItems("select * from tab", "select * from tab".length, { + tables, + columnsByTable, + databaseType: "oracle", + }); + + const tableFunction = items.find((item) => item.label === "TABLE" && item.type === "function"); + assert.ok(tableFunction); + assert.ok(tableFunction.apply?.startsWith("TABLE(")); +}); + +test("suggests package members after package qualifier", () => { + const items = buildSqlCompletionItems("begin PAYROLL.ca", "begin PAYROLL.ca".length, { + tables, + columnsByTable, + objects: [ + { name: "PAYROLL", schema: "HR", type: "package" }, + { name: "calculate_bonus", schema: "HR", type: "function", parentSchema: "HR", parentName: "PAYROLL" }, + ], + databaseType: "oracle", + }); + + const member = items.find((item) => item.label === "calculate_bonus"); + assert.ok(member); + assert.equal(member.type, "function"); + assert.equal(member.apply, "calculate_bonus()"); +}); + test("matches alias qualifier case-insensitively", () => { const sql = "select O. from public.orders o"; const cursor = "select O.".length; @@ -1193,6 +1222,49 @@ test("getSqlCompletionContext returns nonAggregatedSelectColumns", () => { assert.ok(!context.nonAggregatedSelectColumns.includes("id"), "id inside COUNT should not be non-aggregated"); }); +// --- UPDATE / DELETE completion contexts --- + +test("suggests SET immediately after UPDATE target table", () => { + const sql = "update users se"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.equal(items[0]?.label, "SET"); +}); + +test("suggests target table columns inside UPDATE SET clause", () => { + const sql = "update users set na"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.equal(items[0]?.label, "name"); + assert.equal(items[0]?.type, "column"); +}); + +test("suggests WHERE after UPDATE SET assignments", () => { + const sql = "update users set name = 'a' wh"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.equal(items[0]?.label, "WHERE"); +}); + +test("suggests WHERE immediately after DELETE target table", () => { + const sql = "delete from users wh"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + }); + + assert.equal(items[0]?.label, "WHERE"); +}); + // --- Better FK join inference --- test("prefers explicit foreign-key join condition with table aliases", () => { @@ -1232,6 +1304,20 @@ test("suggests explicit foreign-key join when the joined table owns the key", () assert.ok(fkJoin, "should suggest FK join when the right side owns the foreign key"); }); +test("boosts foreign-key related table candidates in JOIN table context", () => { + const foreignKeysByTable = new Map([["public.orders", [{ name: "orders_user_id_fkey", column: "user_id", ref_table: "users", ref_column: "id" }]]]); + const sql = "select * from public.orders o join us"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + foreignKeysByTable, + }); + + assert.equal(items[0]?.label, "users"); + assert.equal(items[0]?.type, "table"); + assert.ok(items[0]?.detail?.includes("related by")); +}); + test("suggests composite explicit foreign-key join conditions", () => { const colsWithCompositeFk = new Map([ [