diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index e852bd984e..4c126c75b2 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -2004,7 +2004,7 @@ func completionRequestHasTableLikeKind(kinds []string) bool { func completionRequestHasRoutineKind(kinds []string) bool { for _, kind := range kinds { switch strings.ToLower(strings.TrimSpace(kind)) { - case "routine", "procedure", "function": + case "routine", "procedure", "function", "sequence": return true } } @@ -2450,13 +2450,54 @@ func oracleCompletionSynonymTargetsQuery(targets []oracleCompletionSynonymTarget } } +func oracleCompletionRoutineObjectTypes(kinds []string) []string { + if len(kinds) == 0 { + return []string{"'FUNCTION'", "'PROCEDURE'", "'PACKAGE'", "'SEQUENCE'"} + } + hasRoutine := false + hasProc := false + hasFunc := false + hasSeq := false + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "routine": + hasRoutine = true + case "procedure": + hasProc = true + case "function": + hasFunc = true + case "sequence": + hasSeq = true + } + } + types := make([]string, 0, 4) + if hasRoutine { + types = append(types, "'FUNCTION'", "'PROCEDURE'", "'PACKAGE'") + } else { + if hasProc { + types = append(types, "'PROCEDURE'") + } + if hasFunc { + types = append(types, "'FUNCTION'") + } + } + if hasSeq { + types = append(types, "'SEQUENCE'") + } + if len(types) == 0 { + return []string{"'FUNCTION'", "'PROCEDURE'", "'PACKAGE'"} + } + return types +} + func oracleCompletionRoutinesQuery(request completionAssistantRequest, preferredSchema string, limit int) oracleMetadataListQuery { pattern := oracleCompletionLikePattern(request.Mask, request.MatchMode) args := make([]any, 0, 5) - baseSQL := ` + objectTypes := oracleCompletionRoutineObjectTypes(request.ObjectKinds) + baseSQL := fmt.Sprintf(` SELECT o.OWNER, o.OBJECT_NAME, o.OBJECT_TYPE, CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME FROM ALL_OBJECTS o -WHERE o.OBJECT_TYPE IN ('FUNCTION', 'PROCEDURE', 'PACKAGE')` +WHERE o.OBJECT_TYPE IN (%s)`, strings.Join(objectTypes, ", ")) args = append(args, pattern) nameParam := len(args) diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index f10f577efe..7b43abdb58 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -2347,6 +2347,30 @@ func TestOracleCompletionRoutinesQueryUsesPublicPackageMetadata(t *testing.T) { } } +func TestOracleCompletionRoutinesQueryIncludesSequences(t *testing.T) { + query := oracleCompletionRoutinesQuery(completionAssistantRequest{ + Schema: "HR", + ObjectKinds: []string{"routine", "sequence"}, + Mask: "SEQ", + GlobalSearch: false, + }, "HR", 100) + sqlText := strings.ToUpper(query.SQL) + if !strings.Contains(sqlText, "'SEQUENCE'") { + t.Fatalf("routines query should include SEQUENCE when requested: %s", query.SQL) + } + + seqOnlyQuery := oracleCompletionRoutinesQuery(completionAssistantRequest{ + Schema: "HR", + ObjectKinds: []string{"sequence"}, + Mask: "SEQ", + GlobalSearch: false, + }, "HR", 100) + seqSQL := strings.ToUpper(seqOnlyQuery.SQL) + if !strings.Contains(seqSQL, "IN ('SEQUENCE')") { + t.Fatalf("sequence-only query should only include SEQUENCE: %s", seqOnlyQuery.SQL) + } +} + func TestOracleCompletionPackageCandidatesPreserveOverloadsAndReturnTypes(t *testing.T) { rows := []oraclePackageRoutineRow{ { diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 225e0ebdf0..45ac875763 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -1411,6 +1411,12 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) function completionObjectKindsForContext(completionContext: ReturnType): CompletionAssistantObjectKind[] { if (completionContext.contextKind === "exec") return ["procedure"]; + if (isOracleCompletionDatabase(props.databaseType)) { + if (completionContext.suggestColumns && completionContext.referencedTables.length > 0 && !completionContext.qualifier) { + return ["function", "sequence"]; + } + return ["routine", "sequence"]; + } if (completionContext.suggestColumns && completionContext.referencedTables.length > 0 && !completionContext.qualifier) return ["function"]; return ["routine"]; } diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index 35b83bec0c..347a8897e4 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -484,6 +484,163 @@ describe("PostgreSQL sequence literal completion", () => { expect(items.some((item) => item.label === "order_seq")).toBe(false); }); + it("suggests Oracle sequences in SQL expressions and select lists", () => { + const sql = "SELECT ord"; + const unqualifiedItems = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "ORDER_SEQ", schema: "APP", type: "sequence" }], + columnsByTable: new Map(), + databaseType: "oracle", + dialect: "oracle", + currentSchema: "APP", + }); + + expect(unqualifiedItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "ORDER_SEQ", + type: "variable", + detail: "sequence in APP", + apply: "ORDER_SEQ", + }), + ]), + ); + + const qualifiedItems = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "ORDER_SEQ", schema: "APP", type: "sequence" }], + columnsByTable: new Map(), + databaseType: "oracle", + dialect: "oracle", + }); + + expect(qualifiedItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "ORDER_SEQ", + type: "variable", + detail: "sequence in APP", + apply: "APP.ORDER_SEQ", + }), + ]), + ); + }); + + it("suggests Oracle sequences under schema qualifier", () => { + const sql = "SELECT app.ord"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "ORDER_SEQ", schema: "APP", type: "sequence" }], + columnsByTable: new Map(), + databaseType: "oracle", + dialect: "oracle", + }); + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "ORDER_SEQ", + type: "variable", + apply: "ORDER_SEQ", + }), + ]), + ); + }); + + it("suggests NEXTVAL and CURRVAL pseudo-columns for Oracle sequences", () => { + const sql = "SELECT ORDER_SEQ."; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "ORDER_SEQ", schema: "APP", type: "sequence" }], + columnsByTable: new Map(), + databaseType: "oracle", + dialect: "oracle", + }); + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "NEXTVAL", + type: "column", + detail: "sequence pseudo-column", + }), + expect.objectContaining({ + label: "CURRVAL", + type: "column", + detail: "sequence pseudo-column", + }), + ]), + ); + }); + + it("suggests NEXTVAL and CURRVAL pseudo-columns for schema-qualified Oracle sequences", () => { + const sql = "SELECT APP.ORDER_SEQ."; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "ORDER_SEQ", schema: "APP", type: "sequence" }], + columnsByTable: new Map(), + databaseType: "oracle", + dialect: "oracle", + }); + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "NEXTVAL", + type: "column", + detail: "sequence pseudo-column", + }), + expect.objectContaining({ + label: "CURRVAL", + type: "column", + detail: "sequence pseudo-column", + }), + ]), + ); + }); + + it("respects keyword case for Oracle sequence pseudo-columns", () => { + const sql = "SELECT ORDER_SEQ.n"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "ORDER_SEQ", schema: "APP", type: "sequence" }], + columnsByTable: new Map(), + databaseType: "oracle", + dialect: "oracle", + keywordCase: "lower", + }); + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "nextval", + apply: "nextval", + }), + ]), + ); + }); + + it("suggests Oracle sequence pseudo-columns in INSERT VALUES clause", () => { + const sql = "INSERT INTO orders (id, name) VALUES (ORDER_SEQ."; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "orders", schema: "APP" }], + objects: [{ name: "ORDER_SEQ", schema: "APP", type: "sequence" }], + columnsByTable: new Map([["APP.orders", [{ name: "id" }, { name: "name" }]]]), + databaseType: "oracle", + dialect: "oracle", + }); + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "NEXTVAL", + type: "column", + detail: "sequence pseudo-column", + }), + ]), + ); + }); + it.each([ ["SELECT 'order_", "postgres"], ["SELECT nextval('order_", "mysql"], diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index d9f45a59d4..c9d1eb3677 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1832,6 +1832,14 @@ class SqlCompletionProvider { this.items.push(...buildColumnItems(context, this.input.columnsByTable, this.dialect, this.databaseType, this.input.quoteIdentifiers)); this.items.push(...buildSelectAllColumnItems(context, this.input.columnsByTable, this.t, this.dialect, this.databaseType)); this.items.push(...buildInsertAllColumnItems(context, this.input.columnsByTable, this.t, this.dialect, this.input.keywordCase)); + if (isOracleLikeDatabase(this.databaseType) && context.qualifier && !completionQualifierIsReferencedTable(context)) { + const profileObjects = driverProfileCompletionObjects(this.input.driverProfile, context); + const allObjects = [...(this.input.objects ?? []), ...profileObjects]; + const hasKnownSequence = allObjects.some((object) => sequenceMatchesQualifier(object, context)); + if (hasKnownSequence || (!completionQualifierIsKnownTableOrSchema(context, completionTables, this.input.schemas) && (matchesPrefix("NEXTVAL", context.prefix) || matchesPrefix("CURRVAL", context.prefix)))) { + this.items.push(...buildOracleSequencePseudoColumnItems(context.prefix, this.input.keywordCase, hasKnownSequence)); + } + } } const emptyTableNameCompletion = !context.prefix && (context.suggestTables || context.exclusiveTableSuggestions); @@ -4453,8 +4461,14 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO const onlyProcedures = context.contextKind === "exec"; const onlyFunctions = context.suggestColumns && context.referencedTables.length > 0 && !context.qualifier; const prioritizeOracleFunctions = isOracleCompletionDatabase(databaseType) && context.statementKind === "select"; + const allowOracleSequences = isOracleCompletionDatabase(databaseType) && !onlyProcedures; return objects - .filter((object) => object.type !== "sequence" && (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context)) + .filter((object) => { + if (object.type === "sequence") { + return allowOracleSequences && objectMatchesCompletionContext(object, context); + } + return (!onlyProcedures || object.type === "procedure") && (!onlyFunctions || (object.type === "function" && object.name.toLowerCase().startsWith(context.prefix.toLowerCase()))) && objectMatchesCompletionContext(object, context); + }) .map((object) => { const qualifiedByContext = objectIsQualifiedByContext(object, context); const objectInCurrentSchema = !!currentSchema && !!object.schema && normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema); @@ -4471,10 +4485,10 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO const baseDedupeKey = object.applyName || (isOracleCompletionDatabase(databaseType) && object.schema) ? applyName : undefined; return { label: object.name, - type: "function" as const, + type: object.type === "sequence" ? ("variable" as const) : ("function" as const), detail, info: buildRoutineInfo(object), - apply: object.type === "trigger" || object.type === "package" ? applyName : buildRoutineApply(applyName, object.signature, includeParams), + apply: object.type === "trigger" || object.type === "package" || object.type === "sequence" ? applyName : buildRoutineApply(applyName, object.signature, includeParams), boost: computeBoost(object.name, context.prefix) + typeBoost + schemaBoost, dedupeKey: signature ? `${baseDedupeKey ?? object.name}(${signature})` : baseDedupeKey, // Preserve exact routine matches before the capped candidate list is truncated. @@ -4539,6 +4553,7 @@ function buildRoutineInfo(object: SqlCompletionObject): string | undefined { function routineTypeBoost(type: SqlCompletionObject["type"], prioritizeFunctions: boolean): number { if (type === "package") return 1600; + if (type === "sequence") return 1500; if (type === "function") return prioritizeFunctions ? 1800 : 900; return prioritizeFunctions ? 900 : 1800; } @@ -4551,6 +4566,55 @@ function completionQualifierIsReferencedTable(context: SqlCompletionContext): bo return context.referencedTables.some((table) => referencedTableMatchesColumnQualifier(table, qualifier, qualifierLower, qualifiedTarget)); } +function sequenceMatchesQualifier(object: SqlCompletionObject, context: SqlCompletionContext): boolean { + if (object.type !== "sequence") return false; + if (!context.qualifier) return false; + const parts = context.qualifierParts?.length ? context.qualifierParts : context.qualifier.split(".").filter(Boolean); + if (parts.length === 1) { + return normalizeIdentifierPart(object.name) === normalizeIdentifierPart(parts[0]); + } + if (parts.length === 2) { + const [schema, name] = parts; + const schemaMatches = !object.schema || normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(schema); + return schemaMatches && normalizeIdentifierPart(object.name) === normalizeIdentifierPart(name); + } + return false; +} + +function completionQualifierIsKnownTableOrSchema(context: SqlCompletionContext, tables: SqlCompletionTable[], schemas?: string[]): boolean { + if (!context.qualifier) return false; + const parts = context.qualifierParts?.length ? context.qualifierParts : context.qualifier.split(".").filter(Boolean); + if (parts.length === 1) { + const name = normalizeIdentifierPart(parts[0]); + if (schemas?.some((s) => normalizeIdentifierPart(s) === name)) return true; + if (tables.some((t) => normalizeIdentifierPart(t.name) === name)) return true; + return false; + } + if (parts.length === 2) { + const [schema, table] = parts; + if (tables.some((t) => normalizeIdentifierPart(t.name) === normalizeIdentifierPart(table) && (!t.schema || normalizeIdentifierPart(t.schema) === normalizeIdentifierPart(schema)))) return true; + return false; + } + return false; +} + +function buildOracleSequencePseudoColumnItems(prefix: string, keywordCase?: SqlKeywordCase, hasKnownSequence = false): SqlCompletionItem[] { + const pseudoColumns = ["NEXTVAL", "CURRVAL"] as const; + return pseudoColumns + .filter((col) => matchesPrefix(col, prefix)) + .map((col) => { + const label = applySqlKeywordCase(col, keywordCase); + const isNextVal = col === "NEXTVAL"; + return { + label, + type: "column" as const, + detail: "sequence pseudo-column", + apply: label, + boost: computeBoost(col, prefix) + (hasKnownSequence ? (isNextVal ? 2200 : 2100) : isNextVal ? 1200 : 1100), + }; + }); +} + function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean { if (!context.qualifier || !object.parentName) return false; const qualifier = context.qualifier.toLowerCase(); @@ -4562,6 +4626,13 @@ function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCom function objectMatchesCompletionContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean { if (context.oracleTableFunctionContext && object.type !== "function") return false; + if (object.type === "sequence") { + if (context.qualifier) { + const qualifier = context.qualifier.toLowerCase(); + return !!object.schema && object.schema.toLowerCase() === qualifier && matchesPrefix(object.name, context.prefix); + } + return matchesPrefix(object.name, context.prefix); + } if (context.qualifier) { const qualifier = context.qualifier.toLowerCase(); const qualifierParts = qualifier.split(".").filter(Boolean); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 71c95d60b4..54b250d304 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -1625,6 +1625,51 @@ describe("connectionStore completion assistant", () => { expect(objects).toEqual([expect.objectContaining({ name: "CALCULATE_BONUS", schema: "HR", type: "function", parentSchema: "HR", parentName: "PAYROLL", dataType: undefined, applyName: "HR.CALCULATE_BONUS", boost: 0 })]); }); + it("maps Oracle sequence objects from completion assistant", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [{ name: "ORDER_SEQ", kind: "sequence", schema: "HR", data_type: "SEQUENCE" }], + incomplete: false, + fallback_used: false, + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + listObjects: vi.fn().mockResolvedValue([]), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + const objects = await store.listCompletionObjects("oracle-1", "ORCL", "ORDER", 20, "HR", undefined, false, "APP", ["routine", "sequence"]); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ object_kinds: ["routine", "sequence"], mask: "ORDER", schema: "APP" })); + expect(objects).toEqual([expect.objectContaining({ name: "ORDER_SEQ", schema: "HR", type: "sequence", dataType: undefined, applyName: "HR.ORDER_SEQ" })]); + }); + + it("maps Oracle sequence objects from listObjects in fallback path", async () => { + const listCompletionObjects = vi.fn().mockResolvedValue([{ name: "ORDER_SEQ", object_type: "SEQUENCE", schema: "HR" }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch: vi.fn().mockRejectedValue(new Error("assistant disabled")), + listCompletionObjects, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + const objects = await store.listCompletionObjects("oracle-1", "ORCL", "ORDER", 20, "HR", undefined, false, "APP", ["routine", "sequence"]); + + expect(objects).toEqual([expect.objectContaining({ name: "ORDER_SEQ", schema: "HR", type: "sequence" })]); + }); + it("loads PostgreSQL routines by prefix and preserves return metadata", async () => { const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [{ name: "st_area", kind: "function", schema: "public", data_type: "double precision", comment: "Returns an area" }], diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index db1a3388b5..9cc682afba 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -8263,7 +8263,7 @@ export const useConnectionStore = defineStore("connection", () => { const candidateType = candidate.data_type?.toUpperCase(); const type = candidate.kind === "procedure" ? "procedure" : candidate.kind === "function" ? "function" : candidate.kind === "sequence" ? "sequence" : candidate.kind === "object" && candidateType === "PACKAGE" ? "package" : null; if (!type) return null; - const dataType = candidate.data_type && !["FUNCTION", "PROCEDURE", "PACKAGE"].includes(candidateType ?? "") ? candidate.data_type : undefined; + const dataType = candidate.data_type && !["FUNCTION", "PROCEDURE", "PACKAGE", "SEQUENCE"].includes(candidateType ?? "") ? candidate.data_type : undefined; return { name: candidate.name, schema: candidate.schema ?? undefined, @@ -9204,7 +9204,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" : objectType.includes("PACKAGE") ? "package" : null; + const type = objectType.includes("PROCEDURE") ? "procedure" : objectType.includes("FUNCTION") ? "function" : objectType.includes("TRIGGER") ? "trigger" : objectType.includes("PACKAGE") ? "package" : objectType.includes("SEQUENCE") ? "sequence" : null; if (!type) return null; return { name: object.name,