diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index 476facc981..cdc2bc4bc4 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -168,6 +168,37 @@ type connectParams struct { ConnectionString string `json:"connection_string"` } +type completionAssistantRequest struct { + ConnectionID string `json:"connection_id"` + Database string `json:"database"` + Schema string `json:"schema"` + ObjectKinds []string `json:"object_kinds"` + Mask string `json:"mask"` + CaseSensitive bool `json:"case_sensitive"` + GlobalSearch bool `json:"global_search"` + MaxResults int `json:"max_results"` + ParentSchema string `json:"parent_schema"` + ParentName string `json:"parent_name"` + MatchMode string `json:"match_mode"` +} + +type completionAssistantCandidate struct { + Name string `json:"name"` + Kind string `json:"kind"` + Database *string `json:"database"` + Schema *string `json:"schema"` + ParentSchema *string `json:"parent_schema"` + ParentName *string `json:"parent_name"` + Comment *string `json:"comment"` + DataType *string `json:"data_type"` +} + +type completionAssistantResponse struct { + Candidates []completionAssistantCandidate `json:"candidates"` + Incomplete bool `json:"incomplete"` + FallbackUsed bool `json:"fallback_used"` +} + type queryOptions struct { SQL string `json:"sql"` Database string `json:"database"` @@ -637,6 +668,13 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any schema := stringParam(params, "schema") result, err := s.listObjects(schema, metadataListConstraintsFromParams(params)) return result, false, err + case "completion_assistant_search_v1": + var request completionAssistantRequest + if err := decodeParams(params, &request); err != nil { + return nil, false, err + } + result, err := s.completionAssistantSearch(request) + return result, false, err case "get_columns": schema := stringParam(params, "schema") table := stringParam(params, "table") @@ -1329,6 +1367,406 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints) return emptyIfNil(result), rows.Err() } +func (s *server) completionAssistantSearch(request completionAssistantRequest) (completionAssistantResponse, error) { + limit := request.MaxResults + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + preferredSchema := strings.ToUpper(strings.TrimSpace(request.Schema)) + if preferredSchema == "" { + var err error + preferredSchema, err = s.currentSchema() + if err != nil { + return completionAssistantResponse{}, err + } + } + + if completionRequestHasTableLikeKind(request.ObjectKinds) { + return s.completionAssistantTables(request, preferredSchema, limit) + } + if completionRequestHasRoutineKind(request.ObjectKinds) { + return s.completionAssistantRoutines(request, preferredSchema, limit) + } + return completionAssistantResponse{Candidates: []completionAssistantCandidate{}}, nil +} + +func completionRequestHasTableLikeKind(kinds []string) bool { + if len(kinds) == 0 { + return true + } + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "table", "view": + return true + } + } + return false +} + +func completionRequestHasRoutineKind(kinds []string) bool { + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "routine", "procedure", "function": + return true + } + } + return false +} + +func (s *server) completionAssistantTables(request completionAssistantRequest, preferredSchema string, limit int) (completionAssistantResponse, error) { + scanLimit := limit * 3 + if scanLimit < limit+1 { + scanLimit = limit + 1 + } + if scanLimit > 1000 { + scanLimit = 1000 + } + query := oracleCompletionTablesQuery(request, preferredSchema, scanLimit+1) + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + return completionAssistantResponse{}, err + } + + type tableRow struct { + owner, name, objectType string + targetOwner, targetName sql.NullString + } + rawRows := make([]tableRow, 0, scanLimit+1) + targets := make([]oracleCompletionSynonymTarget, 0) + for rows.Next() { + var row tableRow + if err := rows.Scan(&row.owner, &row.name, &row.objectType, &row.targetOwner, &row.targetName); err != nil { + s.closeRows(rows) + return completionAssistantResponse{}, err + } + rawRows = append(rawRows, row) + if strings.EqualFold(row.objectType, "SYNONYM") && row.targetOwner.Valid && row.targetName.Valid { + targets = append(targets, oracleCompletionSynonymTarget{Owner: row.targetOwner.String, Name: row.targetName.String}) + } + } + rowsErr := rows.Err() + closeErr := s.closeRows(rows) + if rowsErr != nil { + return completionAssistantResponse{}, rowsErr + } + if closeErr != nil { + return completionAssistantResponse{}, closeErr + } + + validTargets, err := s.oracleCompletionValidSynonymTargets(targets, oracleCompletionTableObjectTypes(request.ObjectKinds)) + if err != nil { + return completionAssistantResponse{}, err + } + candidates := make([]completionAssistantCandidate, 0, limit+1) + for _, row := range rawRows { + if strings.EqualFold(row.objectType, "SYNONYM") { + if !row.targetOwner.Valid || !row.targetName.Valid { + continue + } + if _, ok := validTargets[oracleCompletionSynonymTargetKey(row.targetOwner.String, row.targetName.String)]; !ok { + continue + } + } + kind := "table" + if strings.EqualFold(row.objectType, "VIEW") { + kind = "view" + } + candidates = append(candidates, completionAssistantCandidate{ + Name: row.name, + Kind: kind, + Database: stringPointer(request.Database), + Schema: stringPointer(row.owner), + DataType: stringPointer(row.objectType), + }) + } + incomplete := len(candidates) > limit || len(rawRows) > scanLimit + if incomplete { + if len(candidates) > limit { + candidates = candidates[:limit] + } + } + return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete}, nil +} + +type oracleCompletionSynonymTarget struct { + Owner string + Name string +} + +func oracleCompletionSynonymTargetKey(owner, name string) string { + return owner + "\x00" + name +} + +func (s *server) oracleCompletionValidSynonymTargets(targets []oracleCompletionSynonymTarget, objectTypes []string) (map[string]struct{}, error) { + unique := make([]oracleCompletionSynonymTarget, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + for _, target := range targets { + key := oracleCompletionSynonymTargetKey(target.Owner, target.Name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + unique = append(unique, target) + } + + valid := make(map[string]struct{}, len(unique)) + const batchSize = 100 + for start := 0; start < len(unique); start += batchSize { + end := start + batchSize + if end > len(unique) { + end = len(unique) + } + query := oracleCompletionSynonymTargetsQuery(unique[start:end], objectTypes) + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + return nil, err + } + for rows.Next() { + var owner, name string + if err := rows.Scan(&owner, &name); err != nil { + s.closeRows(rows) + return nil, err + } + valid[oracleCompletionSynonymTargetKey(owner, name)] = struct{}{} + } + rowsErr := rows.Err() + closeErr := s.closeRows(rows) + if rowsErr != nil { + return nil, rowsErr + } + if closeErr != nil { + return nil, closeErr + } + } + return valid, nil +} + +func (s *server) completionAssistantRoutines(request completionAssistantRequest, preferredSchema string, limit int) (completionAssistantResponse, error) { + query := oracleCompletionRoutinesQuery(request, preferredSchema, limit+1) + rows, err := s.queryRows(query.SQL, query.Args) + if err != nil { + return completionAssistantResponse{}, err + } + defer s.closeRows(rows) + + candidates := make([]completionAssistantCandidate, 0, limit+1) + for rows.Next() { + var owner, name, objectType string + var parentName sql.NullString + if err := rows.Scan(&owner, &name, &objectType, &parentName); err != nil { + return completionAssistantResponse{}, err + } + kind := strings.ToLower(objectType) + if objectType == "PACKAGE" { + kind = "object" + } + candidate := completionAssistantCandidate{ + Name: name, + Kind: kind, + Database: stringPointer(request.Database), + Schema: stringPointer(owner), + DataType: stringPointer(objectType), + } + if parentName.Valid { + candidate.ParentSchema = stringPointer(owner) + candidate.ParentName = stringPointer(parentName.String) + } + candidates = append(candidates, candidate) + } + if err := rows.Err(); err != nil { + return completionAssistantResponse{}, err + } + incomplete := len(candidates) > limit + if incomplete { + candidates = candidates[:limit] + } + return completionAssistantResponse{Candidates: candidates, Incomplete: incomplete}, nil +} + +func stringPointer(value string) *string { + if value == "" { + return nil + } + result := value + return &result +} + +func oracleCompletionTableObjectTypes(kinds []string) []string { + objectTypes := make([]string, 0, 2) + for _, kind := range kinds { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "table": + objectTypes = append(objectTypes, "'TABLE'") + case "view": + objectTypes = append(objectTypes, "'VIEW'") + } + } + if len(objectTypes) == 0 { + objectTypes = []string{"'TABLE'", "'VIEW'"} + } + return objectTypes +} + +func oracleCompletionTablesQuery(request completionAssistantRequest, preferredSchema string, limit int) oracleMetadataListQuery { + objectTypes := oracleCompletionTableObjectTypes(request.ObjectKinds) + pattern := oracleCompletionLikePattern(request.Mask, request.MatchMode) + args := make([]any, 0, 7) + args = append(args, pattern) + objectNamePredicate := oracleCompletionNamePredicate("o.OBJECT_NAME", len(args), request.CaseSensitive) + objectOwnerPredicate := "" + synonymOwnerPredicate := "" + owner := "" + if !request.GlobalSearch { + owner = strings.ToUpper(strings.TrimSpace(request.ParentSchema)) + if owner == "" { + owner = strings.ToUpper(strings.TrimSpace(request.Schema)) + } + if owner == "" { + owner = preferredSchema + } + args = append(args, owner) + objectOwnerPredicate = fmt.Sprintf(" AND o.OWNER = :%d", len(args)) + } + args = append(args, pattern) + synonymNamePredicate := oracleCompletionNamePredicate("s.SYNONYM_NAME", len(args), request.CaseSensitive) + if owner != "" { + args = append(args, owner) + synonymOwnerPredicate = fmt.Sprintf(" AND s.OWNER = :%d", len(args)) + } + args = append(args, preferredSchema) + preferredParam := len(args) + args = append(args, strings.TrimSpace(request.Mask)) + exactParam := len(args) + args = append(args, limit) + limitParam := len(args) + + baseSQL := fmt.Sprintf(`SELECT o.OWNER, + o.OBJECT_NAME, + o.OBJECT_TYPE, + CAST(NULL AS VARCHAR2(128)) AS TARGET_OWNER, + CAST(NULL AS VARCHAR2(128)) AS TARGET_NAME + FROM ALL_OBJECTS o + WHERE o.OBJECT_TYPE IN (%s) + AND %s%s + UNION ALL + SELECT s.OWNER, + s.SYNONYM_NAME AS OBJECT_NAME, + 'SYNONYM' AS OBJECT_TYPE, + s.TABLE_OWNER AS TARGET_OWNER, + s.TABLE_NAME AS TARGET_NAME + FROM ALL_SYNONYMS s + WHERE s.DB_LINK IS NULL + AND %s%s`, strings.Join(objectTypes, ","), objectNamePredicate, objectOwnerPredicate, synonymNamePredicate, synonymOwnerPredicate) + unionSQL := fmt.Sprintf("SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME\nFROM (\n%s\n)", baseSQL) + orderedSQL := oracleCompletionOrderedSQL(unionSQL, "OBJECT_NAME", "OBJECT_TYPE", preferredParam, exactParam) + return oracleMetadataListQuery{ + SQL: fmt.Sprintf("SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME FROM (\n%s\n) WHERE ROWNUM <= :%d", orderedSQL, limitParam), + Args: args, + } +} + +func oracleCompletionSynonymTargetsQuery(targets []oracleCompletionSynonymTarget, objectTypes []string) oracleMetadataListQuery { + args := make([]any, 0, len(targets)*2) + predicates := make([]string, 0, len(targets)) + for _, target := range targets { + args = append(args, target.Owner, target.Name) + predicates = append(predicates, fmt.Sprintf("(o.OWNER = :%d AND o.OBJECT_NAME = :%d)", len(args)-1, len(args))) + } + return oracleMetadataListQuery{ + SQL: fmt.Sprintf("SELECT DISTINCT o.OWNER, o.OBJECT_NAME\nFROM ALL_OBJECTS o\nWHERE o.OBJECT_TYPE IN (%s)\n AND (%s)", strings.Join(objectTypes, ","), strings.Join(predicates, " OR ")), + Args: args, + } +} + +func oracleCompletionRoutinesQuery(request completionAssistantRequest, preferredSchema string, limit int) oracleMetadataListQuery { + pattern := oracleCompletionLikePattern(request.Mask, request.MatchMode) + args := make([]any, 0, 6) + baseSQL := ` +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')` + if parentName := strings.ToUpper(strings.TrimSpace(request.ParentName)); parentName != "" { + args = append(args, parentName) + parentParam := len(args) + baseSQL = fmt.Sprintf(` +SELECT p.OWNER, + p.PROCEDURE_NAME AS OBJECT_NAME, + CASE WHEN EXISTS ( + SELECT 1 + FROM ALL_ARGUMENTS a + WHERE a.OWNER = p.OWNER + AND a.OBJECT_ID = p.OBJECT_ID + AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID + AND a.POSITION = 0 + ) THEN 'FUNCTION' ELSE 'PROCEDURE' END AS OBJECT_TYPE, + p.OBJECT_NAME AS PARENT_NAME +FROM ALL_PROCEDURES p +WHERE p.OBJECT_TYPE = 'PACKAGE' + AND p.PROCEDURE_NAME IS NOT NULL + AND p.OBJECT_NAME = :%d`, parentParam) + } + args = append(args, pattern) + nameParam := len(args) + + ownerPredicate := "" + if !request.GlobalSearch { + owner := strings.ToUpper(strings.TrimSpace(request.ParentSchema)) + if owner == "" { + owner = strings.ToUpper(strings.TrimSpace(request.Schema)) + } + if owner == "" { + owner = preferredSchema + } + args = append(args, owner) + ownerPredicate = fmt.Sprintf(" AND OWNER = :%d", len(args)) + } + args = append(args, preferredSchema) + preferredParam := len(args) + args = append(args, strings.TrimSpace(request.Mask)) + exactParam := len(args) + args = append(args, limit) + limitParam := len(args) + + filteredSQL := fmt.Sprintf("SELECT DISTINCT OWNER, OBJECT_NAME, OBJECT_TYPE, PARENT_NAME\nFROM (\n%s\n)\nWHERE %s%s", baseSQL, oracleCompletionNamePredicate("OBJECT_NAME", nameParam, request.CaseSensitive), ownerPredicate) + orderedSQL := oracleCompletionOrderedSQL(filteredSQL, "OBJECT_NAME", "OBJECT_TYPE", preferredParam, exactParam) + return oracleMetadataListQuery{ + SQL: fmt.Sprintf("SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, PARENT_NAME FROM (\n%s\n) WHERE ROWNUM <= :%d", orderedSQL, limitParam), + Args: args, + } +} + +func oracleCompletionNamePredicate(column string, parameter int, caseSensitive bool) string { + if caseSensitive { + return fmt.Sprintf("%s LIKE :%d ESCAPE '\\'", column, parameter) + } + return fmt.Sprintf("UPPER(%s) LIKE UPPER(:%d) ESCAPE '\\'", column, parameter) +} + +func oracleCompletionLikePattern(mask, matchMode string) string { + escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(strings.TrimSpace(mask)) + if strings.EqualFold(strings.TrimSpace(matchMode), "contains") { + return "%" + escaped + "%" + } + return escaped + "%" +} + +func oracleCompletionOrderedSQL(baseSQL, nameColumn, typeColumn string, preferredParam, exactParam int) string { + return fmt.Sprintf(`%s +ORDER BY CASE + WHEN OWNER = :%d THEN 0 + WHEN OWNER = 'PUBLIC' THEN 1 + WHEN OWNER IN ('SYS','SYSTEM','SYSMAN','DBSNMP','OUTLN','XDB','MDSYS','CTXSYS','WMSYS') THEN 3 + ELSE 2 + END, + CASE WHEN UPPER(%s) = UPPER(:%d) THEN 0 ELSE 1 END, + CASE %s WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'FUNCTION' THEN 0 WHEN 'PROCEDURE' THEN 1 WHEN 'PACKAGE' THEN 2 WHEN 'SYNONYM' THEN 3 ELSE 4 END, + %s, + OWNER`, baseSQL, preferredParam, nameColumn, exactParam, typeColumn, nameColumn) +} + func (s *server) getColumns(schema, table string) ([]columnInfo, error) { schema, err := s.normalizeSchema(schema) if err != nil { diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index 70f7dc9cf7..edfa68ad1a 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -828,6 +828,102 @@ func TestOracleFuzzyLikePatternEscapesSpecialCharacters(t *testing.T) { } } +func TestOracleCompletionTablesQuerySearchesAcrossSchemasWithPriority(t *testing.T) { + query := oracleCompletionTablesQuery(completionAssistantRequest{ + Database: "ORCL", + Schema: "APP", + ObjectKinds: []string{"table", "view"}, + Mask: "dept_d", + GlobalSearch: true, + MatchMode: "prefix", + }, "APP", 201) + sqlText := strings.ToUpper(query.SQL) + + if !strings.Contains(sqlText, "ALL_OBJECTS") || !strings.Contains(sqlText, "ALL_SYNONYMS") { + t.Fatalf("global completion should include objects and synonyms: %s", query.SQL) + } + if !strings.Contains(sqlText, "S.TABLE_OWNER AS TARGET_OWNER") || !strings.Contains(sqlText, "S.TABLE_NAME AS TARGET_NAME") || !strings.Contains(sqlText, "S.DB_LINK IS NULL") { + t.Fatalf("table completion should return local synonym targets for bounded validation: %s", query.SQL) + } + if strings.Contains(sqlText, "JOIN ALL_OBJECTS TARGET") { + t.Fatalf("Oracle 11g completion must not join full dictionary views before applying the result limit: %s", query.SQL) + } + if !strings.Contains(sqlText, "SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME\nFROM (\nSELECT O.OWNER") { + t.Fatalf("Oracle 11g requires the union to be wrapped before expression-based ordering: %s", query.SQL) + } + if strings.Contains(sqlText, "WHERE UPPER(OBJECT_NAME) LIKE UPPER(:1) ESCAPE '\\' AND OWNER =") { + t.Fatalf("global completion must not restrict results to one owner: %s", query.SQL) + } + if !strings.Contains(sqlText, "WHEN OWNER = :3 THEN 0") || !strings.Contains(sqlText, "WHERE ROWNUM <= :5") { + t.Fatalf("completion should prioritize the current schema and use Oracle 11g rownum limiting: %s", query.SQL) + } + if len(query.Args) != 5 || query.Args[0] != `dept\_d%` || query.Args[1] != `dept\_d%` || query.Args[2] != "APP" || query.Args[3] != "dept_d" || query.Args[4] != 201 { + t.Fatalf("unexpected completion args: %#v", query.Args) + } +} + +func TestOracleCompletionSynonymTargetsQueryIsBoundedToCandidates(t *testing.T) { + query := oracleCompletionSynonymTargetsQuery([]oracleCompletionSynonymTarget{{Owner: "DBX_TEST", Name: "DEPT_DICT"}, {Owner: "HR", Name: "EMP_VIEW"}}, []string{"'TABLE'", "'VIEW'"}) + sqlText := strings.ToUpper(query.SQL) + + if !strings.Contains(sqlText, "O.OBJECT_TYPE IN ('TABLE','VIEW')") || !strings.Contains(sqlText, "(O.OWNER = :1 AND O.OBJECT_NAME = :2)") || !strings.Contains(sqlText, "(O.OWNER = :3 AND O.OBJECT_NAME = :4)") { + t.Fatalf("synonym target validation should query only returned targets: %s", query.SQL) + } + wantArgs := []any{"DBX_TEST", "DEPT_DICT", "HR", "EMP_VIEW"} + if !reflect.DeepEqual(query.Args, wantArgs) { + t.Fatalf("unexpected synonym target args: %#v", query.Args) + } +} + +func TestOracleCompletionTablesQueryScopesExplicitSchema(t *testing.T) { + query := oracleCompletionTablesQuery(completionAssistantRequest{ + Schema: "APP", + ParentSchema: "HR", + ObjectKinds: []string{"table"}, + Mask: "EMP", + }, "APP", 50) + + if !strings.Contains(strings.ToUpper(query.SQL), "AND O.OWNER = :2") || !strings.Contains(strings.ToUpper(query.SQL), "AND S.OWNER = :4") { + t.Fatalf("explicit schema completion should restrict owner: %s", query.SQL) + } + if len(query.Args) != 7 || query.Args[1] != "HR" || query.Args[3] != "HR" || query.Args[4] != "APP" { + t.Fatalf("unexpected scoped completion args: %#v", query.Args) + } +} + +func TestOracleCompletionRoutinesQueryUsesPublicPackageMetadata(t *testing.T) { + query := oracleCompletionRoutinesQuery(completionAssistantRequest{ + Schema: "HR", + ParentSchema: "HR", + ParentName: "PAYROLL", + ObjectKinds: []string{"routine"}, + Mask: "CALC", + }, "HR", 200) + sqlText := strings.ToUpper(query.SQL) + + if !strings.Contains(sqlText, "ALL_PROCEDURES") || !strings.Contains(sqlText, "ALL_ARGUMENTS") { + t.Fatalf("package completion should use callable procedure metadata: %s", query.SQL) + } + if strings.Contains(sqlText, "ALL_SOURCE") || strings.Contains(sqlText, "PACKAGE BODY") { + t.Fatalf("package completion must not expose private package body source: %s", query.SQL) + } + if !strings.Contains(sqlText, "P.OBJECT_NAME = :1") || !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE UPPER(:2)") || !strings.Contains(sqlText, "AND OWNER = :3") { + t.Fatalf("package completion should scope package and owner: %s", query.SQL) + } + if len(query.Args) != 6 || query.Args[0] != "PAYROLL" || query.Args[1] != "CALC%" || query.Args[2] != "HR" { + t.Fatalf("unexpected package completion args: %#v", query.Args) + } +} + +func TestOracleCompletionLikePatternSupportsPrefixAndContains(t *testing.T) { + if got := oracleCompletionLikePattern(`A_%`, "prefix"); got != `A\_\%%` { + t.Fatalf("prefix pattern = %q", got) + } + if got := oracleCompletionLikePattern("DEPT", "contains"); got != "%DEPT%" { + t.Fatalf("contains pattern = %q", got) + } +} + func TestIsOraclePGALimitError(t *testing.T) { if !isOraclePGALimitError(errors.New("ORA-04036: PGA memory used by the instance exceeds PGA_AGGREGATE_LIMIT")) { t.Fatal("expected ORA-04036 to be detected") diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 6f90d2f26f..65c0a972b5 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1233,7 +1233,7 @@ async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promi const localExact = localMatches.find((item) => completionTablesMatch(item, table)); if (localExact) return localExact; - const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema); + const remoteMatches = await connectionStore.listCompletionTables(props.connectionId, target.database, table.name, MAX_COMPLETION_TABLES, target.schema, false, props.schema); cachedTables = mergeCompletionTables(cachedTables, remoteMatches); return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null; } @@ -1362,12 +1362,12 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) if (cachedTables.length === 0) { cachedTables = usesLocalOnlyCompletionMetadata() ? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema) - : await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema); + : await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, false, props.schema); } let table = matchTable(qualifiedTableLookup, cachedTables) ?? matchTable(tableLookupName, cachedTables) ?? matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables); if (!table && !usesLocalOnlyCompletionMetadata()) { - const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema); + const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema, false, props.schema); cachedTables = [...cachedTables, ...hoverTables]; table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); } @@ -2019,6 +2019,7 @@ async function provideSqlCompletions(context: CompletionContext) { snippets: settingsStore.editorSettings.snippets, dialect: props.dialect, databaseType: props.databaseType, + currentSchema: props.schema, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); @@ -2038,6 +2039,7 @@ async function provideSqlCompletions(context: CompletionContext) { snippets: settingsStore.editorSettings.snippets, dialect: props.dialect, databaseType: props.databaseType, + currentSchema: props.schema, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); @@ -2150,12 +2152,11 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType { cachedTables = mergeCompletionTables(cachedTables, tables); if (completionContext.suggestTables && completionContext.referencedTables.length > 0) { @@ -2257,9 +2260,8 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType {}); } - if (!localOnlyMetadata && (completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions))) { - void connectionStore - .refreshCompletionObjects(connectionId, database, completionContext.prefix, MAX_COMPLETION_TABLES, props.schema) + if (!localOnlyMetadata && shouldLoadCompletionObjects(completionContext)) { + void listCompletionObjectsForContext(completionContext) .then((objects) => { cachedCompletionObjects = mergeCompletionObjects(cachedCompletionObjects, objects); }) @@ -2315,14 +2317,18 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType, incoming: Array<{ name: string; schema?: string; type?: "table" | "view" }>) { +function mergeCompletionTables(existing: SqlCompletionTable[], incoming: SqlCompletionTable[]): SqlCompletionTable[] { const merged = [...existing]; - const seen = new Set(existing.map((table) => `${table.schema ?? ""}.${table.name}`.toLowerCase())); + const indexes = new Map(existing.map((table, index) => [`${table.schema ?? ""}.${table.name}`.toLowerCase(), index])); for (const table of incoming) { const key = `${table.schema ?? ""}.${table.name}`.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - merged.push(table); + const index = indexes.get(key); + if (index == null) { + indexes.set(key, merged.length); + merged.push(table); + } else { + merged[index] = { ...merged[index], ...table }; + } } return merged; } @@ -2331,9 +2337,9 @@ function withCompletionLatencyBudget(remote: Promise, local: T): Promise((resolve) => setTimeout(() => resolve(local), COMPLETION_REMOTE_LATENCY_BUDGET_MS))]); } -function listCompletionTablesWithLatencyBudget(connectionId: string, database: string, filter: string, limit: number, schema?: string): Promise { - const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, schema); - const remote = connectionStore.listCompletionTables(connectionId, database, filter, limit, schema).then((tables) => { +function listCompletionTablesWithLatencyBudget(connectionId: string, database: string, filter: string, limit: number, schema?: string, globalSearch = false): Promise { + const local = connectionStore.lookupLocalCompletionTables(connectionId, database, filter, limit, globalSearch ? undefined : schema); + const remote = connectionStore.listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, props.schema).then((tables) => { cachedTables = mergeCompletionTables(cachedTables, tables); return tables; }); @@ -2341,6 +2347,43 @@ function listCompletionTablesWithLatencyBudget(connectionId: string, database: s return withCompletionLatencyBudget(remote, local); } +interface RoutineCompletionTarget { + schema?: string; + parentName?: string; + globalSearch?: boolean; +} + +function shouldLoadCompletionObjects(completionContext: ReturnType): boolean { + const routineContext = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions); + return routineContext && !isReferencedTableQualifier(completionContext); +} + +function oracleRoutineCompletionTargets(completionContext: ReturnType): RoutineCompletionTarget[] { + const parts = (completionContext.qualifierParts?.length ? completionContext.qualifierParts : completionContext.qualifier?.split("."))?.filter(Boolean) ?? []; + if (parts.length === 0) return [{ schema: props.schema, globalSearch: true }]; + if (parts.length === 1) { + return [{ schema: props.schema, parentName: parts[0] }, { schema: parts[0] }]; + } + return [{ schema: parts[parts.length - 2], parentName: parts[parts.length - 1] }]; +} + +function lookupLocalCompletionObjectsForContext(completionContext: ReturnType): SqlCompletionObject[] { + if (!props.connectionId || props.database == null) return []; + if (props.databaseType === "oracle") { + return connectionStore.lookupLocalCompletionObjects(props.connectionId, props.database, completionContext.prefix, MAX_COMPLETION_TABLES); + } + return connectionStore.lookupLocalCompletionObjects(props.connectionId, props.database, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier && !completionContext.exclusiveColumnSuggestions ? completionContext.qualifier : props.schema); +} + +async function listCompletionObjectsForContext(completionContext: ReturnType): Promise { + if (!props.connectionId || props.database == null) return []; + if (props.databaseType !== "oracle") { + return connectionStore.listCompletionObjects(props.connectionId, props.database, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, props.schema); + } + const groups = await Promise.all(oracleRoutineCompletionTargets(completionContext).map((target) => connectionStore.listCompletionObjects(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, target.schema, target.parentName, target.globalSearch, props.schema))); + return groups.reduce((objects, group) => mergeCompletionObjects(objects, group), [] as SqlCompletionObject[]); +} + async function performAsyncCompletionWithResult(epoch: number, completionContext: ReturnType, fullDoc: string, position: number) { const localOnlyMetadata = usesLocalOnlyCompletionMetadata(); const onDemandOnlyColumns = usesOnDemandOnlyCompletionColumns(); @@ -2377,22 +2420,19 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext completionContext, knownDatabases: databaseNames, }); + const globalOracleTableSearch = props.databaseType === "oracle" && completionContext.suggestTables && !completionContext.qualifier; let tables = shouldLoadTables ? localOnlyMetadata - ? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema) - : await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema) + ? connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, globalOracleTableSearch ? undefined : tableLookupTarget.schema) + : await listCompletionTablesWithLatencyBudget(props.connectionId!, tableLookupTarget.database, tableLookupTarget.filter, MAX_COMPLETION_TABLES, tableLookupTarget.schema, globalOracleTableSearch) : cachedTables; if (epoch !== completionEpoch) return null; - const shouldLoadObjects = completionContext.suggestRoutines || completionContext.exclusiveRoutineSuggestions || (!!completionContext.qualifier && !completionContext.exclusiveColumnSuggestions); - let completionObjects = shouldLoadObjects - ? localOnlyMetadata - ? connectionStore.lookupLocalCompletionObjects(props.connectionId!, props.database!, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, props.schema) - : await connectionStore.listCompletionObjects(props.connectionId!, props.database!, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES, props.schema) - : cachedCompletionObjects; + const shouldLoadObjects = shouldLoadCompletionObjects(completionContext); + let completionObjects = shouldLoadObjects ? (localOnlyMetadata ? lookupLocalCompletionObjectsForContext(completionContext) : await listCompletionObjectsForContext(completionContext)) : cachedCompletionObjects; if (epoch !== completionEpoch) return null; - if (!localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) { + if (props.databaseType !== "oracle" && !localOnlyMetadata && completionContext.qualifier && completionObjects.length === 0) { const schemaObjects = await connectionStore.listCompletionObjects(props.connectionId!, props.database!, completionContext.prefix, MAX_COMPLETION_TABLES, completionContext.qualifier); if (schemaObjects.length > 0) { completionObjects = schemaObjects; @@ -2445,7 +2485,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext }); const unresolvedRefs = refs.filter((rt) => !rt.schema && !rt.columns); if (!localOnlyMetadata && unresolvedRefs.length > 0) { - const lookupGroups = await Promise.all(unresolvedRefs.map((rt) => connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema))); + const lookupGroups = await Promise.all(unresolvedRefs.map((rt) => connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema, false, props.schema))); if (epoch !== completionEpoch) return null; const lookupTables = lookupGroups.flat(); refs = refs.map((rt) => { @@ -2564,6 +2604,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext snippets: settingsStore.editorSettings.snippets, dialect: props.dialect, databaseType: props.databaseType, + currentSchema: props.schema, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); @@ -2584,12 +2625,16 @@ function isTableNameCompletionContext(completionContext: ReturnType `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase())); + const indexes = new Map(existing.map((object, index) => [`${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase(), index])); for (const object of incoming) { const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}`.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - merged.push(object); + const index = indexes.get(key); + if (index == null) { + indexes.set(key, merged.length); + merged.push(object); + } else { + merged[index] = { ...merged[index], ...object }; + } } return merged; } @@ -3098,7 +3143,7 @@ onMounted(async () => { // Some metadata providers only accept table-name masks, not schema.table masks. cachedTables = usesLocalOnlyCompletionMetadata() ? connectionStore.lookupLocalCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema) - : await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema); + : await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema, false, props.schema); } // 1. Check if it's a table name diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 1583806b5e..7ea3815ecf 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1157,6 +1157,9 @@ export interface SqlCompletionTable { name: string; schema?: string; type?: "table" | "view"; + detail?: string; + applyName?: string; + boost?: number; } export interface SqlCompletionObject { @@ -1165,6 +1168,8 @@ export interface SqlCompletionObject { type: "procedure" | "function" | "trigger" | "package"; parentSchema?: string; parentName?: string; + applyName?: string; + boost?: number; } export interface SqlCompletionColumn { @@ -1192,6 +1197,7 @@ export interface SqlCompletionItem { apply?: string; boost: number; exactMatch?: boolean; + dedupeKey?: string; } export function shouldChainSqlCompletionAfterAccept(item: { type?: string; apply?: string }): boolean { @@ -1272,6 +1278,7 @@ export interface SqlCompletionProviderInput { snippets?: SqlSnippet[]; dialect?: "mysql" | "postgres" | "sqlserver"; databaseType?: DatabaseType; + currentSchema?: string; keywordCase?: SqlKeywordCase; autoAliasTables?: boolean; } @@ -1288,6 +1295,7 @@ export function buildSqlCompletionItems( translations?: SqlCompletionTranslations; dialect?: "mysql" | "postgres" | "sqlserver"; databaseType?: DatabaseType; + currentSchema?: string; keywordCase?: SqlKeywordCase; autoAliasTables?: boolean; }, @@ -1375,7 +1383,7 @@ class SqlCompletionProvider { 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, !!this.input.autoAliasTables && context.autoAliasTableCompletions, context.referencedTables, this.databaseType)); + this.items.push(...buildTableItems(context, this.input.tables, this.dialect, !!this.input.autoAliasTables && context.autoAliasTableCompletions, context.referencedTables, this.databaseType, this.input.currentSchema)); if (isOracleLikeDatabase(this.databaseType)) { this.items.push(...buildOracleTableFunctionItems(context.prefix)); } @@ -1385,7 +1393,7 @@ class SqlCompletionProvider { } if (context.suggestRoutines || context.exclusiveRoutineSuggestions || context.oracleTableFunctionContext) { - this.items.push(...buildObjectItems(context, this.input.objects ?? [], this.dialect)); + this.items.push(...buildObjectItems(context, this.input.objects ?? [], this.dialect, this.databaseType, this.input.currentSchema)); } if (context.comparisonLeftColumn && context.suggestKeywords) { @@ -2755,19 +2763,33 @@ function requiresPostgresIdentifierQuote(identifier: string): boolean { const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()).concat(["current_user", "session_user", "user"])); -function buildTableItems(prefix: string, tables: SqlCompletionTable[], dialect?: "mysql" | "postgres" | "sqlserver", autoAliasTables = false, referencedTables: SqlCompletionReferencedTable[] = [], databaseType?: DatabaseType): SqlCompletionItem[] { +function buildTableItems( + context: Pick, + tables: SqlCompletionTable[], + dialect?: "mysql" | "postgres" | "sqlserver", + autoAliasTables = false, + referencedTables: SqlCompletionReferencedTable[] = [], + databaseType?: DatabaseType, + currentSchema?: string, +): SqlCompletionItem[] { + const { prefix } = context; + const qualifierSchema = context.qualifier?.split(".").filter(Boolean).pop(); const existingAliases = new Set(referencedTables.map((ref) => ref.alias?.toLowerCase()).filter((alias): alias is string => !!alias)); return tables .filter((table) => matchesPrefix(table.name, prefix)) .map((table) => { - const applyName = quoteSqlIdentifier(table.name, dialect); + const qualifiedByContext = !!qualifierSchema && !!table.schema && normalizeIdentifierPart(qualifierSchema) === normalizeIdentifierPart(table.schema); + const oracleSchemaQualification = databaseType === "oracle" && table.schema && table.schema.toUpperCase() !== "PUBLIC" && (!currentSchema || normalizeIdentifierPart(table.schema) !== normalizeIdentifierPart(currentSchema)); + const defaultApplyName = oracleSchemaQualification ? `${quoteSqlIdentifier(table.schema!, dialect)}.${quoteSqlIdentifier(table.name, dialect)}` : quoteSqlIdentifier(table.name, dialect); + const applyName = qualifiedByContext ? quoteSqlIdentifier(table.name, dialect) : (table.applyName ?? defaultApplyName); const alias = autoAliasTables ? generateTableCompletionAlias(table.name, existingAliases) : ""; return { label: table.name, type: "table" as const, - detail: table.schema ? `${table.schema}.${table.name}` : table.type, + detail: table.detail ?? (table.schema ? `${table.schema}.${table.name}` : table.type), apply: formatTableAliasApply(applyName, alias, databaseType), - boost: computeBoost(table.name, prefix) + 1000, + boost: computeBoost(table.name, prefix) + 1000 + (table.boost ?? 0), + dedupeKey: table.applyName || (databaseType === "oracle" && table.schema) ? applyName : undefined, }; }) .sort(compareCompletionItems) @@ -2837,25 +2859,27 @@ function buildSchemaItems(prefix: string, schemas: string[], dialect?: "mysql" | })); } -function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionObject[], dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem[] { - const onlyProcedures = context.exclusiveRoutineSuggestions; +function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionObject[], dialect?: "mysql" | "postgres" | "sqlserver", databaseType?: DatabaseType, currentSchema?: string): SqlCompletionItem[] { + if (completionQualifierIsReferencedTable(context)) return []; + const onlyProcedures = context.contextKind === "exec"; + const prioritizeOracleFunctions = databaseType === "oracle" && context.statementKind === "select"; return objects .filter((object) => (!onlyProcedures || object.type === "procedure") && objectMatchesCompletionContext(object, context)) .map((object) => { const qualifiedByContext = objectIsQualifiedByContext(object, context); + const objectInCurrentSchema = !!currentSchema && !!object.schema && normalizeIdentifierPart(object.schema) === normalizeIdentifierPart(currentSchema); const applyName = 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); + : (object.applyName ?? (object.schema && !objectInCurrentSchema ? `${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" || object.type === "package" ? applyName : `${applyName}()`, - boost: computeBoost(object.name, context.prefix) + (object.type === "procedure" ? 1800 : object.type === "package" ? 1600 : 900), + boost: computeBoost(object.name, context.prefix) + routineTypeBoost(object.type, prioritizeOracleFunctions) + (object.boost ?? 0), + dedupeKey: object.applyName || (databaseType === "oracle" && object.schema) ? applyName : undefined, // Preserve exact routine matches before the capped candidate list is truncated. exactMatch: !!context.prefix && object.name.toLowerCase() === context.prefix.toLowerCase(), }; @@ -2864,6 +2888,20 @@ function buildObjectItems(context: SqlCompletionContext, objects: SqlCompletionO .slice(0, MAX_TABLE_COMPLETION_ITEMS); } +function routineTypeBoost(type: SqlCompletionObject["type"], prioritizeFunctions: boolean): number { + if (type === "package") return 1600; + if (type === "function") return prioritizeFunctions ? 1800 : 900; + return prioritizeFunctions ? 900 : 1800; +} + +function completionQualifierIsReferencedTable(context: SqlCompletionContext): boolean { + if (!context.qualifier) return false; + const qualifier = context.qualifier; + const qualifierLower = qualifier.toLowerCase(); + const qualifiedTarget = qualifiedTableTargetFromContext(context); + return context.referencedTables.some((table) => referencedTableMatchesColumnQualifier(table, qualifier, qualifierLower, qualifiedTarget)); +} + function objectIsQualifiedByContext(object: SqlCompletionObject, context: SqlCompletionContext): boolean { if (!context.qualifier || !object.parentName) return false; const qualifier = context.qualifier.toLowerCase(); @@ -4059,7 +4097,7 @@ function getHistoryBoost(label: string, type: string): number { function dedupeAndSort(items: SqlCompletionItem[]): SqlCompletionItem[] { const seen = new Set(); return items.sort(compareCompletionItems).filter((item) => { - const key = `${item.type}:${item.label}`; + const key = `${item.type}:${item.dedupeKey ?? item.label}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 14855a39d7..2870dc311b 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -25,6 +25,18 @@ function postgresConnection(): ConnectionConfig { } as ConnectionConfig; } +function oracleConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "oracle-1", + name: "Oracle 11g", + db_type: "oracle", + port: 1521, + username: "APP", + database: "ORCL", + } as ConnectionConfig; +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -147,6 +159,66 @@ describe("connectionStore completion assistant", () => { expect(tables).toEqual([{ name: "TEST_USERS", schema: "SYSDBA", type: "table" }]); }); + it("maps global Oracle tables with safe qualification and schema priority", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [ + { name: "DEPT_DICT", kind: "table", schema: "APP", data_type: "TABLE" }, + { name: "DEPT_DICT", kind: "view", schema: "COMM", data_type: "VIEW" }, + { name: "V_DEPT_DICT", kind: "view", schema: "SYS", data_type: "VIEW" }, + { name: "DEPT_DICT_ALIAS", kind: "table", schema: "PUBLIC", data_type: "SYNONYM" }, + ], + incomplete: false, + fallback_used: false, + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + listTables: vi.fn().mockResolvedValue([]), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + const tables = await store.listCompletionTables("oracle-1", "ORCL", "DEPT_D", 20, "APP", true); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema: "APP", parent_schema: null, global_search: true, mask: "DEPT_D" })); + expect(tables).toEqual([ + expect.objectContaining({ name: "DEPT_DICT", schema: "APP", applyName: "DEPT_DICT", boost: 2400 }), + expect.objectContaining({ name: "DEPT_DICT", schema: "COMM", applyName: "COMM.DEPT_DICT", boost: 0 }), + expect.objectContaining({ name: "V_DEPT_DICT", schema: "SYS", applyName: "SYS.V_DEPT_DICT", boost: -1200 }), + expect.objectContaining({ name: "DEPT_DICT_ALIAS", schema: "PUBLIC", applyName: "DEPT_DICT_ALIAS", detail: "PUBLIC · synonym", boost: 1200 }), + ]); + }); + + it("maps Oracle package members without scanning every schema", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [{ name: "CALCULATE_BONUS", kind: "function", schema: "HR", parent_schema: "HR", parent_name: "PAYROLL", data_type: "FUNCTION" }], + 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", "CALC", 20, "HR", "PAYROLL", false, "APP"); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ object_kinds: ["routine"], mask: "CALC", schema: "APP", parent_schema: "HR", parent_name: "PAYROLL", global_search: false })); + expect(objects).toEqual([expect.objectContaining({ name: "CALCULATE_BONUS", schema: "HR", type: "function", parentSchema: "HR", parentName: "PAYROLL", applyName: "HR.CALCULATE_BONUS", boost: 0 })]); + }); + it("limits concurrent completion column metadata requests per connection database", async () => { const gates = [deferred(), deferred(), deferred(), deferred()]; let activeColumns = 0; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index c646244595..a04111dc62 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -4002,14 +4002,60 @@ export const useConnectionStore = defineStore("connection", () => { }); } - function completionAssistantTables(candidates: CompletionAssistantCandidate[]): SqlCompletionTable[] { + const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]); + + function completionPreferredSchema(connectionId: string, preferredSchema?: string): string | undefined { + return preferredSchema?.trim() || getConfig(connectionId)?.username?.trim() || undefined; + } + + function completionCandidateSchemaBoost(schema: string | null | undefined, preferredSchema?: string): number { + if (schema && preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase()) return 2400; + if (schema?.toUpperCase() === "PUBLIC") return 1200; + if (schema && ORACLE_SYSTEM_COMPLETION_SCHEMAS.has(schema.toUpperCase())) return -1200; + return 0; + } + + function completionCandidateApplyName(name: string, schema: string | null | undefined, preferredSchema?: string): string { + if (!schema || schema.toUpperCase() === "PUBLIC" || (preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase())) return name; + return `${schema}.${name}`; + } + + function completionAssistantTables(candidates: CompletionAssistantCandidate[], preferredSchema?: string, withOracleMetadata = false): SqlCompletionTable[] { return candidates .filter((candidate) => candidate.kind === "table" || candidate.kind === "view") - .map((candidate) => ({ - name: candidate.name, - schema: candidate.schema ?? undefined, - type: candidate.kind === "view" ? ("view" as const) : ("table" as const), - })); + .map((candidate) => { + const table: SqlCompletionTable = { + name: candidate.name, + schema: candidate.schema ?? undefined, + type: candidate.kind === "view" ? "view" : "table", + }; + if (!withOracleMetadata) return table; + return { + ...table, + detail: candidate.schema ? `${candidate.schema} · ${(candidate.data_type || candidate.kind).toLowerCase()}` : candidate.kind, + applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema), + boost: completionCandidateSchemaBoost(candidate.schema, preferredSchema), + }; + }); + } + + function completionAssistantObjects(candidates: CompletionAssistantCandidate[], preferredSchema?: string): SqlCompletionObject[] { + return candidates + .map((candidate): SqlCompletionObject | null => { + const candidateType = candidate.data_type?.toUpperCase(); + const type = candidate.kind === "procedure" ? "procedure" : candidate.kind === "function" ? "function" : candidate.kind === "object" && candidateType === "PACKAGE" ? "package" : null; + if (!type) return null; + return { + name: candidate.name, + schema: candidate.schema ?? undefined, + type, + parentSchema: candidate.parent_schema ?? undefined, + parentName: candidate.parent_name ?? undefined, + applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema), + boost: completionCandidateSchemaBoost(candidate.schema, preferredSchema), + }; + }) + .filter((object): object is SqlCompletionObject => object != null); } function completionAssistantColumns(candidates: CompletionAssistantCandidate[], table: string, schema?: string): SqlCompletionColumn[] { @@ -4024,23 +4070,45 @@ export const useConnectionStore = defineStore("connection", () => { })); } - async function listCompletionAssistantTables(connectionId: string, database: string, filter: string, limit?: number, schema?: string): Promise { + async function listCompletionAssistantTables(connectionId: string, database: string, filter: string, limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise { + const oracleAssistant = getConfig(connectionId)?.db_type === "oracle"; + const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, currentSchema) : schema?.trim() || undefined; const objectKinds: CompletionAssistantObjectKind[] = ["table", "view"]; const response = await completionAssistantSearch({ connection_id: connectionId, database, - schema: schema ?? null, + schema: preferredSchema ?? null, object_kinds: objectKinds, mask: filter.trim(), max_results: limit ?? 200, - parent_schema: schema ?? null, + global_search: globalSearch, + parent_schema: globalSearch ? null : (schema ?? null), match_mode: "prefix", }); - const tables = completionAssistantTables(response.candidates); + const tables = completionAssistantTables(response.candidates, preferredSchema, oracleAssistant); indexCompletionTables(connectionId, database, schema, tables); return tables; } + async function listCompletionAssistantObjects(connectionId: string, database: string, filter: string, limit: number | undefined, schema: string | undefined, parentName: string | undefined, globalSearch: boolean, currentSchema?: string): Promise { + const preferredSchema = completionPreferredSchema(connectionId, currentSchema); + const response = await completionAssistantSearch({ + connection_id: connectionId, + database, + schema: preferredSchema ?? null, + object_kinds: ["routine"], + mask: filter.trim(), + max_results: limit ?? 200, + global_search: globalSearch, + parent_schema: globalSearch ? null : (schema ?? null), + parent_name: parentName ?? null, + match_mode: "prefix", + }); + const objects = completionAssistantObjects(response.candidates, preferredSchema); + indexCompletionObjects(connectionId, database, schema, objects); + return objects; + } + async function listCompletionAssistantColumns(connectionId: string, database: string, table: string, schema?: string): Promise { const response = await completionAssistantSearch({ connection_id: connectionId, @@ -4341,7 +4409,7 @@ export const useConnectionStore = defineStore("connection", () => { }); } - async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise { + async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise { const trimmedFilter = filter.trim(); const normalizedFilter = trimmedFilter.toLowerCase(); // Remote queries (Dameng/Oracle) are case-sensitive, so the cache key must @@ -4349,7 +4417,7 @@ export const useConnectionStore = defineStore("connection", () => { // second lookup returns the first's stale results. Local lookups below stay // case-insensitive because tableMatchScore normalizes internally. const relaxedFilter = relaxedCompletionTableFilter(trimmedFilter); - const cacheKey = `${connectionId}:${database}:${trimmedFilter}:${limit ?? ""}:${schema ?? ""}`; + const cacheKey = `${connectionId}:${database}:${trimmedFilter}:${limit ?? ""}:${schema ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}`; if (completionTablesCache.value[cacheKey]) { return completionTablesCache.value[cacheKey]; } @@ -4363,7 +4431,7 @@ export const useConnectionStore = defineStore("connection", () => { if (normalizedFilter || limit) { let results: SqlCompletionTable[] = []; try { - results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema); + results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema); } catch { if (schema) { const tables = await api.listTables(connectionId, database, schema, trimmedFilter, limit); @@ -4377,7 +4445,13 @@ export const useConnectionStore = defineStore("connection", () => { } } if (results.length === 0 && relaxedFilter) { - if (schema) { + if (globalSearch) { + try { + results = await listCompletionAssistantTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), schema, true, currentSchema); + } catch { + results = []; + } + } else if (schema) { try { const tables = await api.listTables(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit)); results = tables.map((table) => ({ @@ -4453,16 +4527,21 @@ export const useConnectionStore = defineStore("connection", () => { return deduped; } - async function listCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise { + async function listCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string): Promise { const normalizedFilter = filter.trim().toLowerCase(); - const cacheKey = `${connectionId}:${database}:${schema ?? ""}`; + const oracleAssistant = getConfig(connectionId)?.db_type === "oracle" && (!!normalizedFilter || typeof limit === "number" || !!parentName || globalSearch); + const cacheKey = oracleAssistant ? `${connectionId}:${database}:${schema ?? ""}:${parentName ?? ""}:${normalizedFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}` : `${connectionId}:${database}:${schema ?? ""}`; if (!completionObjectsCache.value[cacheKey]) { await withCompletionInFlight( `${cacheKey}:objects`, async () => { await ensureConnected(connectionId); - const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database); - completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); + if (oracleAssistant) { + completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema)); + } else { + const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database); + completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); + } indexCompletionObjects(connectionId, database, schema, completionObjectsCache.value[cacheKey]); evictOldestCacheEntries(completionObjectsCache.value, COMPLETION_CACHE_MAX); }, @@ -4614,12 +4693,12 @@ export const useConnectionStore = defineStore("connection", () => { return foreignKeys; } - function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise { - return listCompletionTables(connectionId, database, filter, limit, schema); + function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise { + return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema); } - function refreshCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string): Promise { - return listCompletionObjects(connectionId, database, filter, limit, schema); + function refreshCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string): Promise { + return listCompletionObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema); } function refreshCompletionSchemas(connectionId: string, database: string): Promise { diff --git a/docs/screenshot-oracle-multi-schema-completion.png b/docs/screenshot-oracle-multi-schema-completion.png new file mode 100644 index 0000000000..f6bc61dd85 Binary files /dev/null and b/docs/screenshot-oracle-multi-schema-completion.png differ diff --git a/docs/screenshot-oracle-routine-completion.png b/docs/screenshot-oracle-routine-completion.png new file mode 100644 index 0000000000..f5258e18e0 Binary files /dev/null and b/docs/screenshot-oracle-routine-completion.png differ diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 727383ddab..69d70ae517 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -400,6 +400,20 @@ test("suggests only matching columns for an explicit alias qualifier prefix", () ); }); +test("does not mix routines into an explicit table alias column completion", () => { + const sql = "select u.na from public.users u"; + const cursor = "select u.na".length; + const items = buildSqlCompletionItems(sql, cursor, { + tables, + columnsByTable, + objects: [{ name: "name_formatter", schema: "u", type: "function", applyName: "u.name_formatter" }], + databaseType: "oracle", + }); + + assert.ok(items.some((item) => item.label === "name" && item.type === "column")); + assert.equal(items.some((item) => item.label === "name_formatter"), false); +}); + test("keeps explicit alias column suggestions scoped to the alias table", () => { const sql = "select * from public.users u join public.orders o on u.id = o.user_id where o.st"; const items = buildSqlCompletionItems(sql, sql.length, { @@ -1555,6 +1569,88 @@ test("suggests package members after package qualifier", () => { assert.equal(member.apply, "calculate_bonus()"); }); +test("keeps Oracle functions available in qualified expression routine context", () => { + const sql = "select HGY.FN_"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables, + columnsByTable, + objects: [ + { name: "FN_CHECKIDCARD", schema: "HGY", type: "function" }, + { name: "FN_REFRESH", schema: "HGY", type: "procedure" }, + ], + databaseType: "oracle", + }); + + const fn = items.find((item) => item.label === "FN_CHECKIDCARD"); + assert.ok(fn); + assert.equal(fn.apply, "FN_CHECKIDCARD()"); + assert.equal(items.find((item) => item.type === "function")?.label, "FN_CHECKIDCARD"); +}); + +test("prioritizes current Oracle schema tables and safely qualifies other schemas", () => { + const sql = "select * from dept_d"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [ + { name: "DEPT_DICT", schema: "COMM", type: "table", applyName: "COMM.DEPT_DICT", boost: 0 }, + { name: "DEPT_DICT", schema: "APP", type: "table", applyName: "DEPT_DICT", boost: 2400 }, + { name: "DEPT_DICT", schema: "SYS", type: "view", applyName: "SYS.DEPT_DICT", boost: -1200 }, + ], + columnsByTable, + databaseType: "oracle", + }); + const matches = items.filter((item) => item.label === "DEPT_DICT"); + + assert.deepEqual( + matches.map((item) => item.apply), + ["DEPT_DICT", "COMM.DEPT_DICT", "SYS.DEPT_DICT"], + ); +}); + +test("does not duplicate an Oracle schema qualifier when applying a scoped table", () => { + const sql = "select * from COMM.DEPT_D"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [{ name: "DEPT_DICT", schema: "COMM", type: "table" }], + columnsByTable, + databaseType: "oracle", + currentSchema: "APP", + }); + + const table = items.find((item) => item.label === "DEPT_DICT"); + assert.ok(table); + assert.equal(table.apply, "DEPT_DICT"); +}); + +test("keeps same-name Oracle routines from different schemas", () => { + const sql = "select FN_CHECK"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [ + { name: "FN_CHECK", schema: "APP", type: "function", boost: 2400 }, + { name: "FN_CHECK", schema: "HR", type: "function" }, + ], + columnsByTable: new Map(), + databaseType: "oracle", + currentSchema: "APP", + }); + + assert.deepEqual( + items.filter((item) => item.label === "FN_CHECK").map((item) => item.apply), + ["FN_CHECK()", "HR.FN_CHECK()"], + ); +}); + +test("still deduplicates built-in and database routines outside Oracle metadata search", () => { + const sql = "select DATE_FORMAT"; + const items = buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [{ name: "DATE_FORMAT", schema: "app", type: "function" }], + columnsByTable: new Map(), + databaseType: "mysql", + }); + + assert.equal(items.filter((item) => item.type === "function" && item.label === "DATE_FORMAT").length, 1); +}); + test("matches alias qualifier case-insensitively", () => { const sql = "select O. from public.orders o"; const cursor = "select O.".length;