diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift index 6874698878..d2a6349ab4 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift @@ -278,7 +278,9 @@ public final class SQLContextAnalyzer { "|WINDOW|FETCH|FOR)\\b|[;()]|$)" ) - private static let alterTableRegex = compileRegex("(?i)\\bALTER\\s+TABLE\\s+(\(quotedSegment))") + private static let alterTableRegex = compileRegex( + "(?i)\\bALTER\\s+TABLE\\s+(\(quotedSegment)(?:\\.\(quotedSegment))*)" + ) private static let derivedTableParser = DerivedTableParser() @@ -288,6 +290,19 @@ public final class SQLContextAnalyzer { public init() {} + // `analyze` reads no tables when its cursor sits in a comment or literal; this reads them wherever the statement + // ends, so `SELECT * FROM orders -- note` still names `orders`. A CTE's name comes back like a table's, since a + // CTE can share a table's name and still read that table in its own body. + public func tableReferences(in statement: String, grammar: SQLLexicalGrammar) -> [TableReference] { + let nsStatement = statement as NSString + let text = Self.lex(nsStatement, cursor: nsStatement.length, grammar: grammar) + var references = extractTableReferences(from: text.forReferences) + if let alterRef = extractAlterTableReference(from: text.forReferences), !references.contains(alterRef) { + references.append(alterRef) + } + return references + } + // MARK: - Main Analysis public func analyze(query: String, cursorPosition: Int, grammar: SQLLexicalGrammar) -> SQLContext { @@ -328,8 +343,7 @@ public final class SQLContextAnalyzer { var seenReferences = Set(tableReferences) - if let alterTableName = extractAlterTableName(from: text.forReferences) { - let alterRef = TableReference(tableName: alterTableName, alias: nil) + if let alterRef = extractAlterTableReference(from: text.forReferences) { if seenReferences.insert(alterRef).inserted { tableReferences.append(alterRef) } @@ -1003,12 +1017,19 @@ public final class SQLContextAnalyzer { ) } - private func extractAlterTableName(from text: LexedStatement) -> String? { + private func extractAlterTableReference(from text: LexedStatement) -> TableReference? { let nsRange = NSRange(location: 0, length: text.code.length) guard let match = Self.alterTableRegex.firstMatch(in: text.codeString, range: nsRange) else { return nil } - let tableNSRange = match.range(at: 1) - guard tableNSRange.location != NSNotFound else { return nil } - return Self.unquoted(text.original.substring(with: tableNSRange)) + let pathRange = match.range(at: 1) + guard pathRange.location != NSNotFound else { return nil } + let segments = Self.pathSegments(pathRange, in: text) + guard let table = segments.last, !table.name.isEmpty else { return nil } + return TableReference( + tableName: table.name, + alias: nil, + schema: segments.count >= 2 ? segments[segments.count - 2].name : nil, + isIdentifierQuoted: table.isQuoted + ) } // MARK: - Clause Detection diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SchemaRelevanceRanker.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SchemaRelevanceRanker.swift new file mode 100644 index 0000000000..5e19f843e4 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SchemaRelevanceRanker.swift @@ -0,0 +1,200 @@ +import Foundation + +public struct SchemaRelevanceDocument: Sendable, Equatable { + public let name: String + public let details: [String] + + public init(name: String, details: [String] = []) { + self.name = name + self.details = details + } +} + +// Orders tables by how well their names and columns match a question: BM25 over identifier +// words, with trigram matching for words that miss every exact term (typos, inflections). +public enum SchemaRelevanceRanker { + static let nameWeight = 3 + static let fullNameBoost = 5.0 + static let fuzzyThreshold = 0.5 + static let fuzzyMinLength = 4 + static let k1 = 1.2 + static let b = 0.75 + + public static func rank(_ documents: [SchemaRelevanceDocument], for query: String) -> [Int] { + let queryWords = Set(words(in: query)) + guard !queryWords.isEmpty, !documents.isEmpty else { return [] } + + let index = Index(documents: documents) + let matches = queryWords.flatMap { index.matches(for: $0) } + let orderedQueryWords = words(in: query) + + var scored: [(offset: Int, score: Double)] = [] + for (offset, terms) in index.termCounts.enumerated() { + var score = 0.0 + for match in matches { + guard let frequency = terms[match.term] else { continue } + score += match.weight * index.bm25(frequency: frequency, idfTerm: match.term, length: index.lengths[offset]) + } + let nameWords = index.nameWords[offset] + if nameWords.joined().count >= fuzzyMinLength, containsSequence(orderedQueryWords, nameWords) { + score += fullNameBoost + } + if score > 0 { + scored.append((offset, score)) + } + } + + return scored + .sorted { $0.score != $1.score ? $0.score > $1.score : $0.offset < $1.offset } + .map(\.offset) + } + + private static func containsSequence(_ words: [String], _ sequence: [String]) -> Bool { + guard !sequence.isEmpty, words.count >= sequence.count else { return false } + return (0...(words.count - sequence.count)).contains { start in + Array(words[start..<(start + sequence.count)]) == sequence + } + } + + static func words(in text: String) -> [String] { + var result: [String] = [] + var current = "" + var previous: Character? + + func flush() { + if !current.isEmpty { + result.append(stem(current)) + current = "" + } + } + + for character in text { + guard character.isLetter || character.isNumber else { + flush() + previous = nil + continue + } + if let previous, startsNewWord(previous: previous, current: character) { + flush() + } + current += fold(character) + previous = character + } + flush() + return result + } + + // Folding leaves "đ" alone because Unicode treats it as its own letter, not a "d" with a mark. + private static func fold(_ character: Character) -> String { + if character == "đ" || character == "Đ" { return "d" } + return String(character).folding(options: [.diacriticInsensitive, .caseInsensitive, .widthInsensitive], locale: nil) + } + + private static func startsNewWord(previous: Character, current: Character) -> Bool { + if previous.isLowercase, current.isUppercase { return true } + if previous.isLetter, current.isNumber { return true } + if previous.isNumber, current.isLetter { return true } + return false + } + + static func stem(_ word: String) -> String { + guard word.count > 3, word.allSatisfy(\.isASCII) else { return word } + if word.hasSuffix("ies") { return String(word.dropLast(3)) + "y" } + if word.hasSuffix("sses") || word.hasSuffix("xes") || word.hasSuffix("ches") || word.hasSuffix("shes") { + return String(word.dropLast(2)) + } + if word.hasSuffix("s"), !word.hasSuffix("ss"), !word.hasSuffix("us"), !word.hasSuffix("is") { + return String(word.dropLast()) + } + return word + } + + static func trigramSimilarity(_ lhs: String, _ rhs: String) -> Double { + trigramSimilarity(trigrams(lhs), trigrams(rhs)) + } + + private static func trigramSimilarity(_ left: Set, _ right: Set) -> Double { + guard !left.isEmpty, !right.isEmpty else { return 0 } + return Double(left.intersection(right).count) / Double(left.union(right).count) + } + + private static func trigrams(_ word: String) -> Set { + let padded = Array(" \(word) ") + guard padded.count >= 3 else { return [] } + return Set((0...(padded.count - 3)).map { String(padded[$0..<($0 + 3)]) }) + } + + private struct Match { + let term: String + let weight: Double + } + + private struct Index { + let termCounts: [[String: Int]] + let nameWords: [[String]] + let lengths: [Int] + let documentFrequency: [String: Int] + let fuzzyTerms: [(term: String, trigrams: Set)] + let averageLength: Double + + init(documents: [SchemaRelevanceDocument]) { + var termCounts: [[String: Int]] = [] + var nameWords: [[String]] = [] + var lengths: [Int] = [] + var documentFrequency: [String: Int] = [:] + termCounts.reserveCapacity(documents.count) + + for document in documents { + let name = SchemaRelevanceRanker.words(in: document.name) + var counts: [String: Int] = [:] + for word in name { + counts[word, default: 0] += SchemaRelevanceRanker.nameWeight + } + for detail in document.details { + for word in SchemaRelevanceRanker.words(in: detail) { + counts[word, default: 0] += 1 + } + } + for term in counts.keys { + documentFrequency[term, default: 0] += 1 + } + termCounts.append(counts) + nameWords.append(name) + lengths.append(counts.values.reduce(0, +)) + } + + self.termCounts = termCounts + self.nameWords = nameWords + self.lengths = lengths + self.documentFrequency = documentFrequency + self.fuzzyTerms = documentFrequency.keys + .filter { $0.count >= SchemaRelevanceRanker.fuzzyMinLength } + .map { ($0, SchemaRelevanceRanker.trigrams($0)) } + let total = lengths.reduce(0, +) + self.averageLength = documents.isEmpty ? 0 : max(Double(total) / Double(documents.count), 1) + } + + func matches(for word: String) -> [Match] { + if documentFrequency[word] != nil { + return [Match(term: word, weight: 1)] + } + guard word.count >= SchemaRelevanceRanker.fuzzyMinLength else { return [] } + let wordTrigrams = SchemaRelevanceRanker.trigrams(word) + return fuzzyTerms.compactMap { entry in + let similarity = SchemaRelevanceRanker.trigramSimilarity(wordTrigrams, entry.trigrams) + return similarity >= SchemaRelevanceRanker.fuzzyThreshold ? Match(term: entry.term, weight: similarity) : nil + } + } + + func bm25(frequency: Int, idfTerm: String, length: Int) -> Double { + let documentCount = Double(termCounts.count) + let containing = Double(documentFrequency[idfTerm] ?? 0) + let idf = log(1 + (documentCount - containing + 0.5) / (containing + 0.5)) + let tf = Double(frequency) + let k1 = SchemaRelevanceRanker.k1 + let b = SchemaRelevanceRanker.b + let norm = k1 * (1 - b + b * Double(length) / averageLength) + return idf * (tf * (k1 + 1)) / (tf + norm) + } + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift new file mode 100644 index 0000000000..f45cfea954 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTableReferenceTests.swift @@ -0,0 +1,49 @@ +import Foundation +@testable import TableProAutocomplete +import TableProSQLGrammar +import Testing + +struct SQLContextAnalyzerTableReferenceTests { + private let analyzer = SQLContextAnalyzer() + + private func tables(_ statement: String) -> [String] { + analyzer.tableReferences(in: statement, grammar: .ansi).map(\.tableName) + } + + @Test("A statement that ends in a comment still names its tables") + func trailingComment() { + #expect(tables("SELECT * FROM orders -- recent orders") == ["orders"]) + #expect(tables("SELECT * FROM orders /* recent */") == ["orders"]) + #expect(analyzer.analyze(query: "SELECT * FROM orders -- recent", cursorPosition: 30).tableReferences.isEmpty) + } + + @Test("A statement that ends in a string still names its tables") + func trailingLiteral() { + #expect(tables("SELECT * FROM orders WHERE note = 'open") == ["orders"]) + } + + @Test("A CTE that shares a table's name still names the table its body reads") + func shadowingCommonTableExpression() { + let references = analyzer.tableReferences( + in: "WITH orders AS (SELECT * FROM orders WHERE active) SELECT * FROM orders", + grammar: .ansi + ) + #expect(references.contains { $0.tableName == "orders" && $0.derivedColumns == nil }) + } + + @Test("Joined, updated and altered tables are named") + func everyClause() { + #expect(tables("SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id") == ["orders", "customers"]) + #expect(tables("UPDATE customers SET name = 'x'").contains("customers")) + #expect(tables("ALTER TABLE invoices ADD COLUMN due DATE") == ["invoices"]) + } + + @Test("A schema-qualified ALTER target keeps its schema") + func qualifiedAlterTarget() { + let references = analyzer.tableReferences(in: "ALTER TABLE archive.orders ADD COLUMN due DATE", grammar: .ansi) + #expect(references.map(\.tableName) == ["orders"]) + #expect(references.first?.schema == "archive") + let context = analyzer.analyze(query: "ALTER TABLE archive.orders DROP COLUMN ", cursorPosition: 39) + #expect(context.tableReferences.contains { $0.tableName == "orders" && $0.schema == "archive" }) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SchemaRelevanceRankerTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SchemaRelevanceRankerTests.swift new file mode 100644 index 0000000000..39bf8f6b65 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SchemaRelevanceRankerTests.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import TableProAutocomplete +import Testing + +struct SchemaRelevanceRankerTests { + private let documents = [ + SchemaRelevanceDocument(name: "audit_log", details: ["id", "created_at", "message"]), + SchemaRelevanceDocument(name: "customers", details: ["id", "email", "full_name"]), + SchemaRelevanceDocument(name: "order_items", details: ["order_id", "product_id", "quantity"]), + SchemaRelevanceDocument(name: "orders", details: ["id", "customer_id", "total_amount", "created_at"]), + SchemaRelevanceDocument(name: "products", details: ["id", "title", "price"]), + SchemaRelevanceDocument(name: "ProductCategory", details: ["id", "label"]) + ] + + private func names(_ query: String) -> [String] { + SchemaRelevanceRanker.rank(documents, for: query).map { documents[$0].name } + } + + @Test("Splits snake case, camel case and digits into folded, stemmed words") + func splitsIdentifiers() { + #expect(SchemaRelevanceRanker.words(in: "order_items") == ["order", "item"]) + #expect(SchemaRelevanceRanker.words(in: "ProductCategories") == ["product", "category"]) + #expect(SchemaRelevanceRanker.words(in: "userId2fa") == ["user", "id", "2", "fa"]) + #expect(SchemaRelevanceRanker.words(in: "Đơn hàng") == ["don", "hang"]) + } + + @Test("A table named in the question ranks first") + func namedTableRanksFirst() { + #expect(names("total amount of orders per customer").first == "orders") + } + + @Test("A word that only a column holds still finds its table") + func columnWordFindsTable() { + #expect(names("which rows have an email").first == "customers") + } + + @Test("A misspelled table name matches by trigrams") + func typoMatches() { + #expect(names("list all custmers").first == "customers") + } + + @Test("A camel case table matches its plural words") + func camelCaseMatches() { + #expect(names("product categories").contains("ProductCategory")) + } + + @Test("A question that matches nothing ranks nothing") + func noMatchIsEmpty() { + #expect(names("hello there").isEmpty) + #expect(SchemaRelevanceRanker.rank(documents, for: "").isEmpty) + #expect(SchemaRelevanceRanker.rank([], for: "orders").isEmpty) + } + + @Test("A table name inside a longer word gets no name boost") + func nameInsideLongerWordIsNotBoosted() { + let documents = [ + SchemaRelevanceDocument(name: "data"), + SchemaRelevanceDocument(name: "events", details: ["metadata"]) + ] + #expect(SchemaRelevanceRanker.rank(documents, for: "show the metadata").first == 1) + } + + @Test("Equal scores keep the listed order") + func tiesKeepListedOrder() { + let twins = [ + SchemaRelevanceDocument(name: "b_events"), + SchemaRelevanceDocument(name: "a_events") + ] + #expect(SchemaRelevanceRanker.rank(twins, for: "events") == [0, 1]) + } +} diff --git a/apple/TablePro/Core/AI/AISchemaContext.swift b/apple/TablePro/Core/AI/AISchemaContext.swift index b4090c2249..8d09184397 100644 --- a/apple/TablePro/Core/AI/AISchemaContext.swift +++ b/apple/TablePro/Core/AI/AISchemaContext.swift @@ -6,7 +6,9 @@ // import Foundation +import TableProAutocomplete import TableProPluginKit +import TableProSQLGrammar /// One table as the schema context describes it. The producer joins each table to its own columns /// and foreign keys, because a lookup by name cannot tell two schemas' tables of the same name apart. @@ -22,11 +24,11 @@ struct AISchemaTable: Sendable { } } -/// Builds schema context for AI system prompts struct AISchemaContext { + static let maxQueryLength = 2_000 + // MARK: - Public - /// Build a system prompt including database context static func buildSystemPrompt( databaseType: DatabaseType, databaseName: String, @@ -66,11 +68,8 @@ struct AISchemaContext { let query = currentQuery, !query.isEmpty { let lang = editorLanguage.codeBlockTag - let maxQueryLength = 2_000 - let nsQuery = query as NSString - let truncated = nsQuery.length > maxQueryLength - ? nsQuery.substring(to: maxQueryLength) + "\n-- ... truncated" - : query + let carried = carriedQuery(query) + let truncated = carried.utf16.count < query.utf16.count ? carried + "\n-- ... truncated" : query parts.append("\n## Current Query\n```\(lang)\n\(truncated)\n```") } @@ -164,4 +163,67 @@ struct AISchemaContext { return lines.joined(separator: "\n") } + + // The editor's tables come first, since the model is reading that query; the question's matches + // follow, best first. + static func relevantTableNames( + in tables: [TableInfo], + columnsByTable: [String: [ColumnInfo]], + question: String, + queryReferences: [TableReference] = [], + defaultSchema: String? = nil, + maxTables: Int + ) -> [String] { + guard tables.count > maxTables else { return [] } + var names = indices(of: queryReferences, in: tables, defaultSchema: defaultSchema).map { tables[$0].name } + // Columns count only once every table has them, or a table loaded on an earlier turn would + // outrank a better match whose columns were never read. + let columns = tables.allSatisfy { columnsByTable[$0.name] != nil } ? columnsByTable : [:] + let documents = tables.map { table in + var details = (columns[table.name] ?? []).flatMap { [$0.name, $0.comment ?? ""] } + if let comment = table.comment { details.append(comment) } + return SchemaRelevanceDocument(name: table.name, details: details) + } + var seen = Set(names) + for offset in SchemaRelevanceRanker.rank(documents, for: question) where seen.insert(tables[offset].name).inserted { + names.append(tables[offset].name) + } + return names + } + + static func carriedQuery(_ query: String) -> String { + let nsQuery = query as NSString + return nsQuery.length > maxQueryLength ? nsQuery.substring(to: maxQueryLength) : query + } + + // A CTE's name matches no table in the catalog, so `indices` drops it. + static func referencedTables(in query: String, grammar: SQLLexicalGrammar) -> [TableReference] { + let analyzer = SQLContextAnalyzer() + return SQLStatementScanner.allStatements(in: query, grammar: grammar).flatMap { statement in + analyzer.tableReferences(in: statement, grammar: grammar) + } + } + + // Every table a name could mean, in the order the query names them. How an engine folds an + // unquoted name differs, so a name that matches two tables by case or schema puts both first: + // a spare slot is cheaper than missing the table the query reads. The default schema's comes + // first for a bare name, as it does when the query runs. + static func indices(of references: [TableReference], in tables: [TableInfo], defaultSchema: String? = nil) -> [Int] { + func sameName(_ lhs: String?, _ rhs: String?) -> Bool { + guard let lhs, let rhs else { return false } + return lhs.caseInsensitiveCompare(rhs) == .orderedSame + } + var result: [Int] = [] + for reference in references { + let named = tables.indices.filter { sameName(tables[$0].name, reference.tableName) } + let schema = reference.schema ?? defaultSchema + let inSchema = named.filter { sameName(tables[$0].schema, schema) } + let unscoped = named.filter { tables[$0].schema == nil } + let elsewhere = reference.schema == nil ? named : [] + for offset in inSchema + unscoped + elsewhere where !result.contains(offset) { + result.append(offset) + } + } + return result + } } diff --git a/apple/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift b/apple/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift index 0edde1a65f..266516fc4c 100644 --- a/apple/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift +++ b/apple/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift @@ -62,7 +62,7 @@ final class AIChatInlineSource: InlineSuggestionSource { ChatTurnWire(role: .user, blocks: [.text(userMessage)]) ] - let systemPrompt = await buildSystemPrompt(settings: settings) + let systemPrompt = await buildSystemPrompt(settings: settings, context: context) guard accessGate.allowsUnpromptedAccess(to: connectionId) else { return nil } @@ -94,13 +94,17 @@ final class AIChatInlineSource: InlineSuggestionSource { // MARK: - Private - private func buildSystemPrompt(settings: AISettings) async -> String { + private func buildSystemPrompt(settings: AISettings, context: SuggestionContext) async -> String { guard settings.includeSchema, let provider = schemaProvider else { return AIPromptTemplates.inlineSuggestSystemPrompt() } - let schemaContext = await provider.buildSchemaContextForAI(settings: settings) + let schemaContext = await provider.buildSchemaContextForAI( + settings: settings, + editorText: context.fullText, + cursorOffset: context.cursorOffset + ) if let schemaContext, !schemaContext.isEmpty { return AIPromptTemplates.inlineSuggestSystemPrompt(schemaContext: schemaContext) diff --git a/apple/TablePro/Core/AI/InlineSuggestion/AppleIntelligenceInlineSource.swift b/apple/TablePro/Core/AI/InlineSuggestion/AppleIntelligenceInlineSource.swift index 945c5d98f3..7b61d44b44 100644 --- a/apple/TablePro/Core/AI/InlineSuggestion/AppleIntelligenceInlineSource.swift +++ b/apple/TablePro/Core/AI/InlineSuggestion/AppleIntelligenceInlineSource.swift @@ -45,7 +45,11 @@ final class AppleIntelligenceInlineSource: InlineSuggestionSource { var candidates: [OnDeviceRequest] = [] if settings.includeSchema, let provider = schemaProvider, - let schema = await provider.buildSchemaContextForAI(settings: settings), !schema.isEmpty { + let schema = await provider.buildSchemaContextForAI( + settings: settings, + editorText: context.fullText, + cursorOffset: context.cursorOffset + ), !schema.isEmpty { candidates.append(OnDeviceRequest( instructions: AIPromptTemplates.inlineSuggestSystemPrompt(schemaContext: schema), prompt: prompt diff --git a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 5b63da0a12..6fb7ea2c7a 100644 --- a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -9,6 +9,7 @@ import Foundation import os import TableProAutocomplete import TableProPluginKit +import TableProSQLGrammar actor SQLSchemaProvider { private static let logger = Logger(subsystem: "com.TablePro", category: "SQLSchemaProvider") @@ -430,18 +431,10 @@ actor SQLSchemaProvider { // MARK: - AI Schema Context - func buildSchemaContextForAI(settings: AISettings) async -> String? { + // The tables the statement at the cursor reads take the column budget first. + func buildSchemaContextForAI(settings: AISettings, editorText: String? = nil, cursorOffset: Int = 0) async -> String? { guard !tables.isEmpty, let connection = connectionInfo else { return nil } - let listedTables = tables - var schemaTables: [AISchemaTable] = [] - schemaTables.reserveCapacity(listedTables.count) - for table in listedTables.prefix(settings.maxSchemaTables) { - let columns = await getColumns(for: table.name, schema: table.schema) - schemaTables.append(AISchemaTable(table: table, columns: columns)) - } - schemaTables += listedTables.dropFirst(settings.maxSchemaTables).map { AISchemaTable(table: $0) } - let dbType = connection.type let capturedConnection = connection let defaultSchema = getDefaultSchema() @@ -455,6 +448,32 @@ actor SQLSchemaProvider { return (resolvedName, quote, lang, langName) } + var listedTables = tables + if let editorText, editorLanguage == .sql, listedTables.count > settings.maxSchemaTables { + let grammar = dbType.lexicalGrammar + let statement = SQLStatementScanner.statementAtCursor( + in: editorText, + cursorPosition: cursorOffset, + grammar: grammar + ) + let first = AISchemaContext.indices( + of: AISchemaContext.referencedTables(in: statement, grammar: grammar), + in: listedTables, + defaultSchema: defaultSchema + ) + let picked = Set(first) + listedTables = first.map { listedTables[$0] } + + listedTables.indices.filter { !picked.contains($0) }.map { listedTables[$0] } + } + + var schemaTables: [AISchemaTable] = [] + schemaTables.reserveCapacity(listedTables.count) + for table in listedTables.prefix(settings.maxSchemaTables) { + let columns = await getColumns(for: table.name, schema: table.schema) + schemaTables.append(AISchemaTable(table: table, columns: columns)) + } + schemaTables += listedTables.dropFirst(settings.maxSchemaTables).map { AISchemaTable(table: $0) } + return AISchemaContext.buildSystemPrompt( databaseType: dbType, databaseName: dbName, diff --git a/apple/TablePro/Core/Database/DatabaseDriver.swift b/apple/TablePro/Core/Database/DatabaseDriver.swift index 247b95c9a5..ca902e7dc8 100644 --- a/apple/TablePro/Core/Database/DatabaseDriver.swift +++ b/apple/TablePro/Core/Database/DatabaseDriver.swift @@ -152,6 +152,9 @@ protocol DatabaseDriver: AnyObject, Sendable { /// Default implementation falls back to per-table fetchColumns. func fetchAllColumns() async throws -> [String: [ColumnInfo]] + // False when `fetchAllColumns` degrades to one round trip per table. + var providesBulkColumnFetch: Bool { get } + /// Dotted field paths a document store exposes for a collection, for query authoring. /// Default implementation returns nothing, which is correct for every SQL driver. func sampleFieldPaths(table: String, limit: Int) async throws -> [PluginFieldPath] @@ -748,6 +751,8 @@ extension DatabaseDriver { return results } + var providesBulkColumnFetch: Bool { false } + var providesBulkForeignKeyFetch: Bool { false } func fetchAllForeignKeys(schema: String?) async throws -> [String: [ForeignKeyInfo]] { diff --git a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift index 63b22e4b15..8be9661e5d 100644 --- a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -902,6 +902,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.sampleFieldPaths(table: table, schema: pluginDriver.currentSchema, limit: limit) } + var providesBulkColumnFetch: Bool { pluginDriver.providesBulkColumnFetch } + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { let pluginResult = try await pluginDriver.fetchAllColumns(schema: pluginDriver.currentSchema) return pluginResult.mapValues { $0.map(ColumnInfo.init) } diff --git a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index cddca848f7..4c30f410e6 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -5,6 +5,7 @@ import Foundation import os +import TableProAutocomplete import TableProPluginKit extension AIChatViewModel { @@ -23,36 +24,44 @@ extension AIChatViewModel { } func ensureColumnsLoaded(forTable tableName: String) async { - if let existing = columnsByTable[tableName], !existing.isEmpty { return } + dropSchemaCacheIfScopeChanged() + // The ranking's bulk read fills columns without foreign keys, so each is checked on its own. + let needsColumns = (columnsByTable[tableName] ?? []).isEmpty + if !needsColumns, foreignKeysByTable[tableName] != nil { return } if let inFlight = inFlightColumnFetches[tableName] { await inFlight.value return } guard let connection else { return } let connId = connection.id + let scope = schemaCacheScope let task: Task = Task { [weak self] in - let columns: [ColumnInfo] - do { - columns = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId) { driver in - try await driver.fetchColumns(table: tableName) + var columns: [ColumnInfo]? + if needsColumns { + do { + columns = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId) { driver in + try await driver.fetchColumns(table: tableName) + } + } catch { + Self.logger.warning("Column fetch failed for \(tableName, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)") + columns = [] } - } catch { - Self.logger.warning("Column fetch failed for \(tableName, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)") - columns = [] } - let fkMap: [String: [ForeignKeyInfo]] + let fkMap: [String: [ForeignKeyInfo]]? do { fkMap = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId) { driver in try await driver.fetchForeignKeys(forTables: [tableName]) } } catch { Self.logger.warning("Foreign key fetch failed for \(tableName, privacy: .private(mask: .hash)): \(error.publicLogShape, privacy: .public)") - fkMap = [:] + fkMap = nil } - guard !Task.isCancelled, let self else { return } - self.columnsByTable[tableName] = columns - if let fks = fkMap[tableName] { - self.foreignKeysByTable[tableName] = fks + guard !Task.isCancelled, let self, self.schemaCacheScope == scope else { return } + if let columns { + self.columnsByTable[tableName] = columns + } + if let fkMap { + self.foreignKeysByTable[tableName] = fkMap[tableName] ?? [] } self.inFlightColumnFetches[tableName] = nil } @@ -60,18 +69,27 @@ extension AIChatViewModel { await task.value } - func ensureSchemaLoaded() async { + func ensureSchemaLoaded(relevantTo question: String? = nil) async { + dropSchemaCacheIfScopeChanged() if let inFlight = inFlightSchemaLoad { await inFlight.value - return + // The load in flight fetched for the previous order; a new order may need other tables. + guard question != nil else { return } } let task: Task = Task { [weak self] in guard let self else { return } + if let question { + await self.loadEveryColumnForRanking() + guard !Task.isCancelled else { return } + self.prioritizeSchema(for: question) + } await self.runSchemaLoad() } inFlightSchemaLoad = task await task.value - inFlightSchemaLoad = nil + if inFlightSchemaLoad == task { + inFlightSchemaLoad = nil + } } func ensureSavedQueryLoaded(id: UUID) async { @@ -97,8 +115,9 @@ extension AIChatViewModel { private func runSchemaLoad() async { guard let connection else { return } let connId = connection.id + let scope = schemaCacheScope let settings = services.appSettings.ai - let tablesToFetch = Array(tables.prefix(settings.maxSchemaTables)) + let tablesToFetch = Array(orderedTables().prefix(settings.maxSchemaTables)) guard !tablesToFetch.isEmpty else { return } await withTaskGroup(of: (String, [ColumnInfo]).self) { group in @@ -116,12 +135,12 @@ extension AIChatViewModel { } } } - for await (name, cols) in group { + for await (name, cols) in group where schemaCacheScope == scope { columnsByTable[name] = cols } } - guard !Task.isCancelled else { return } + guard !Task.isCancelled, schemaCacheScope == scope else { return } let needsFKFetch = tablesToFetch.contains { foreignKeysByTable[$0.name] == nil } guard needsFKFetch else { return } @@ -129,8 +148,9 @@ extension AIChatViewModel { let fkMap = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId, workload: .bulk) { driver in try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name)) } - for (name, fks) in fkMap { - foreignKeysByTable[name] = fks + guard schemaCacheScope == scope else { return } + for table in tablesToFetch { + foreignKeysByTable[table.name] = fkMap[table.name] ?? [] } } catch { Self.logger.warning("Foreign key bulk fetch failed: \(error.publicLogShape, privacy: .public)") @@ -193,7 +213,7 @@ extension AIChatViewModel { /// `tables` lists the one schema the session browses, where a table name is unique, so the /// name-keyed column and foreign key maps join to it by name. private func schemaTables() -> [AISchemaTable] { - tables.map { table in + orderedTables().map { table in AISchemaTable( table: table, columns: columnsByTable[table.name] ?? [], @@ -202,6 +222,107 @@ extension AIChatViewModel { } } + private func prioritizeSchema(for question: String) { + let ranked = AISchemaContext.relevantTableNames( + in: tables, + columnsByTable: columnsByTable, + question: question, + queryReferences: editorQueryReferences(), + defaultSchema: connection.flatMap { defaultSchema(for: $0) }, + maxTables: services.appSettings.ai.maxSchemaTables + ) + if !ranked.isEmpty { + relevantTableNames = ranked + } + } + + // The editor steers the order only while the model is shown the editor's query. + private func editorQueryReferences() -> [TableReference] { + guard services.appSettings.ai.includeCurrentQuery, + let connection, + let query = currentQuery, !query.isEmpty, + services.pluginManager.editorLanguage(for: connection.type) == .sql + else { return [] } + return AISchemaContext.referencedTables( + in: AISchemaContext.carriedQuery(query), + grammar: connection.type.lexicalGrammar + ) + } + + private func loadEveryColumnForRanking() async { + guard let connection else { return } + let tableCount = tables.count + guard tableCount > services.appSettings.ai.maxSchemaTables, + tableCount <= SQLSchemaProvider.maxCachedTables, + bulkColumnReadTableCount != tableCount + else { return } + bulkColumnReadTableCount = tableCount + let scope = schemaCacheScope + let allColumns: [String: [ColumnInfo]]? + do { + allColumns = try await DatabaseManager.shared.withBrowseMetadataDriver( + connectionId: connection.id, + workload: .bulk + ) { driver in + // A driver without a bulk read would fall back to one query per table. + guard driver.providesBulkColumnFetch else { return nil } + return try await driver.fetchAllColumns() + } + } catch { + Self.logger.warning("Bulk column fetch failed: \(error.publicLogShape, privacy: .public)") + if schemaCacheScope == scope { + bulkColumnReadTableCount = nil + } + return + } + guard let allColumns, !Task.isCancelled, schemaCacheScope == scope else { return } + // A name that differs only by case is trusted only when it can mean one table, or a + // zero-column `orders` would take a quoted `"Orders"`'s columns. + let foldedTableCounts = Dictionary(tables.map { ($0.name.lowercased(), 1) }, uniquingKeysWith: +) + let byFoldedName = Dictionary(grouping: allColumns, by: { $0.key.lowercased() }) + .compactMapValues { $0.count == 1 ? $0[0].value : nil } + var merged = columnsByTable + // A table the read left out, such as a SQLite view, stays unknown, so the ranker never + // takes this read for a complete one. + for table in tables where (merged[table.name] ?? []).isEmpty { + let folded = table.name.lowercased() + if let columns = allColumns[table.name] ?? (foldedTableCounts[folded] == 1 ? byFoldedName[folded] : nil) { + merged[table.name] = columns + } + } + columnsByTable = merged + } + + // A session outlives a database or schema switch, so its column cache belongs to the scope it + // was read in. + private func dropSchemaCacheIfScopeChanged() { + let scope = connection.map { connection in + [ + connection.id.uuidString, + services.databaseManager.browseDatabaseName(for: connection), + defaultSchema(for: connection) ?? "" + ].joined(separator: "\u{1F}") + } + guard scope != schemaCacheScope else { return } + schemaCacheScope = scope + columnsByTable = [:] + foreignKeysByTable = [:] + relevantTableNames = [] + bulkColumnReadTableCount = nil + inFlightColumnFetches.removeAll() + inFlightSchemaLoad?.cancel() + inFlightSchemaLoad = nil + } + + private func orderedTables() -> [TableInfo] { + let all = tables + guard !relevantTableNames.isEmpty else { return all } + let byName = Dictionary(all.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + let relevant = relevantTableNames.compactMap { byName[$0] } + let picked = Set(relevant.map(\.name)) + return relevant + all.filter { !picked.contains($0.name) } + } + private func defaultSchema(for connection: DatabaseConnection) -> String? { connection.type.implicitSchemaName ?? services.databaseManager.session(for: connection.id)?.browseSchema diff --git a/apple/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift b/apple/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift index 36eb178746..700113965c 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift @@ -39,7 +39,16 @@ extension AIChatViewModel { let invocationText = body.isEmpty ? "/\(command.name)" : "/\(command.name) \(body)" let needsSchema = command.promptTemplate.contains(CustomSlashCommandVariable.schema.placeholder) if needsSchema { - await ensureSchemaLoaded() + // The policy check comes later, when the turn is sent, so a read the user has not + // allowed stays as small as it was: no ranking, no bulk column read. + switch connection == nil ? nil : resolveConnectionPolicy(settings: services.appSettings.ai) { + case .never?: + break + case .askEachTime?: + await ensureSchemaLoaded() + case .alwaysAllow?, nil: + await ensureSchemaLoaded(relevantTo: body) + } } let renderingContext = CustomSlashCommandRenderer.Context( query: currentQuery, @@ -49,7 +58,7 @@ extension AIChatViewModel { ) let prompt = CustomSlashCommandRenderer.render(command, context: renderingContext) messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)])) - sendWithContext(prompt: prompt) + sendWithContext(prompt: prompt, schemaQuestion: needsSchema ? body : nil) } private func showHelp(invocationText: String) { diff --git a/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift index 3c4fb970d3..25000d75a0 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -127,10 +127,11 @@ extension AIChatViewModel { let assistantID = assistantMessage.id streamingState = .streaming(assistantID: assistantID) + let question = Self.schemaQuestion(in: messages, override: schemaQuestionOverride) prepTask = Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } if settings.includeSchema { - await self.ensureSchemaLoaded() + await self.ensureSchemaLoaded(relevantTo: question) } if Task.isCancelled { return } let priorTurns: [ChatTurn] = await MainActor.run { @@ -513,6 +514,20 @@ extension AIChatViewModel { ) } + static func schemaQuestion(in messages: [ChatTurn], override: (turnID: UUID, text: String)?) -> String? { + // A turn that only returns tool results is the app's, not the user's. + let lastUserTurn = messages.last { turn in + turn.role == .user && !turn.blocks.allSatisfy { block in + if case .toolResult = block.kind { return true } + return false + } + } + if let override, override.turnID == lastUserTurn?.id { + return override.text + } + return lastUserTurn?.plainText + } + nonisolated static func buildSystemPrompt( _ promptContext: PromptContext?, mode: AIChatMode, diff --git a/apple/TablePro/ViewModels/AIChatViewModel.swift b/apple/TablePro/ViewModels/AIChatViewModel.swift index be8e9d6341..3df1451e56 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel.swift @@ -107,6 +107,12 @@ final class AIChatViewModel: ObservableObject { var queryContextMetadata: any ScopedMetadataProviding var inFlightColumnFetches: [String: Task] = [:] var inFlightSchemaLoad: Task? + var relevantTableNames: [String] = [] + var bulkColumnReadTableCount: Int? + var schemaCacheScope: String? + // A custom command's message carries the schema it rendered, so its tables are ranked by the + // user's own words instead. + var schemaQuestionOverride: (turnID: UUID, text: String)? nonisolated(unsafe) var streamingTask: Task? var prepTask: Task? @@ -238,8 +244,9 @@ final class AIChatViewModel: ObservableObject { return descriptor.supportsImages(fetched: catalog.fetchedInfo(providerID: config.id, modelID: model)) } - func sendWithContext(prompt: String) { + func sendWithContext(prompt: String, schemaQuestion: String? = nil) { let userMessage = ChatTurn(role: .user, blocks: [.text(prompt)]) + schemaQuestionOverride = schemaQuestion.map { (userMessage.id, $0) } messages.append(userMessage) trimMessagesIfNeeded() clearError() @@ -365,6 +372,10 @@ final class AIChatViewModel: ObservableObject { inFlightColumnFetches.removeAll() inFlightSchemaLoad?.cancel() inFlightSchemaLoad = nil + relevantTableNames = [] + bulkColumnReadTableCount = nil + schemaCacheScope = nil + schemaQuestionOverride = nil currentQuery = nil queryResults = nil editorTarget = nil diff --git a/apple/TableProTests/Core/AI/AISchemaContextRelevanceTests.swift b/apple/TableProTests/Core/AI/AISchemaContextRelevanceTests.swift new file mode 100644 index 0000000000..8ea0fa4908 --- /dev/null +++ b/apple/TableProTests/Core/AI/AISchemaContextRelevanceTests.swift @@ -0,0 +1,168 @@ +// +// AISchemaContextRelevanceTests.swift +// TableProTests +// +// A schema with more tables than the prompt's column budget spends that budget on the tables +// the editor's query reads and the question names. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProSQLGrammar +import Testing + +struct AISchemaContextRelevanceTests { + private static let tables: [TableInfo] = [ + TestFixtures.makeTableInfo(name: "audit_log"), + TestFixtures.makeTableInfo(name: "customers"), + TestFixtures.makeTableInfo(name: "invoices"), + TableInfo(name: "tbl_x", type: .table, rowCount: 0, comment: "Monthly revenue snapshots"), + TestFixtures.makeTableInfo(name: "orders") + ] + + private static let grammar = DatabaseType.postgresql.lexicalGrammar + + private static func names( + question: String, + query: String = "", + columns: [String: [ColumnInfo]] = [:], + maxTables: Int = 2 + ) -> [String] { + AISchemaContext.relevantTableNames( + in: tables, + columnsByTable: columns, + question: question, + queryReferences: AISchemaContext.referencedTables(in: query, grammar: grammar), + maxTables: maxTables + ) + } + + private static func everyTableColumns(_ extra: [String: [ColumnInfo]]) -> [String: [ColumnInfo]] { + var columns = Dictionary(uniqueKeysWithValues: tables.map { ($0.name, [TestFixtures.makeColumnInfo(name: "id")]) }) + columns.merge(extra) { _, new in new } + return columns + } + + @Test("Tables that match the question come first, best first") + func matchingTablesComeFirst() { + let names = Self.names(question: "How many orders did each customer place?") + #expect(Array(names.prefix(2)).sorted() == ["customers", "orders"]) + } + + @Test("A table comment counts as a match") + func commentMatches() { + #expect(Self.names(question: "revenue by month").first == "tbl_x") + } + + @Test("Columns count once every table has them") + func columnsMatchWhenEveryTableHasThem() { + let columns = Self.everyTableColumns(["invoices": [TestFixtures.makeColumnInfo(name: "due_date")]]) + #expect(Self.names(question: "unpaid past the due date", columns: columns).first == "invoices") + } + + @Test("Columns loaded for some tables only do not count") + func partialColumnsAreIgnored() { + let columns = ["audit_log": [TestFixtures.makeColumnInfo(name: "customer_note")]] + #expect(Self.names(question: "customer", columns: columns).first == "customers") + #expect(!Self.names(question: "note", columns: columns).contains("audit_log")) + } + + @Test("Nothing is reordered when every table fits") + func everyTableFits() { + #expect(Self.names(question: "orders", maxTables: Self.tables.count).isEmpty) + } + + @Test("Tables the editor's query reads come before the question's matches") + func queryTablesComeFirst() { + let names = Self.names( + question: "why is this slow for customers", + query: "SELECT * FROM invoices i JOIN audit_log a ON a.id = i.id" + ) + #expect(Array(names.prefix(3)) == ["invoices", "audit_log", "customers"]) + } + + @Test("SQL keywords in the editor's query match no table") + func queryKeywordsMatchNothing() { + let names = Self.names( + question: "", + query: "SELECT id FROM customers GROUP BY id ORDER BY id" + ) + #expect(names == ["customers"]) + } + + @Test("Every statement in the editor counts, and a CTE name matches no table") + func everyStatementCounts() { + let query = """ + WITH recent AS (SELECT * FROM orders) SELECT * FROM recent; + UPDATE customers SET name = 'x'; + """ + #expect(Self.names(question: "", query: query) == ["orders", "customers"]) + } + + @Test("A CTE named like a table puts that table first") + func shadowingCommonTableExpression() { + let query = "WITH orders AS (SELECT * FROM orders WHERE total > 0) SELECT * FROM orders" + #expect(Self.names(question: "", query: query) == ["orders"]) + } + + @Test("A schema-qualified reference matches only a table in that schema") + func schemaQualifiedReference() { + let tables = [ + TestFixtures.makeTableInfo(name: "orders", schema: "archive"), + TestFixtures.makeTableInfo(name: "orders", schema: "public") + ] + let references = AISchemaContext.referencedTables(in: "SELECT * FROM public.orders", grammar: Self.grammar) + #expect(AISchemaContext.indices(of: references, in: tables) == [1]) + } + + @Test("A bare name puts the default schema's table first") + func bareNameMeansDefaultSchema() { + let tables = [ + TestFixtures.makeTableInfo(name: "orders", schema: "archive"), + TestFixtures.makeTableInfo(name: "orders", schema: "public") + ] + let references = AISchemaContext.referencedTables(in: "SELECT * FROM orders", grammar: Self.grammar) + #expect(AISchemaContext.indices(of: references, in: tables, defaultSchema: "public") == [1, 0]) + } + + @Test("A name that matches two tables by case puts both first") + func caseCollisionKeepsBoth() { + let tables = [ + TestFixtures.makeTableInfo(name: "audit_log", schema: "public"), + TestFixtures.makeTableInfo(name: "Orders", schema: "public"), + TestFixtures.makeTableInfo(name: "orders", schema: "public") + ] + for query in ["SELECT * FROM Orders", "SELECT * FROM \"orders\""] { + let references = AISchemaContext.referencedTables(in: query, grammar: Self.grammar) + #expect(Set(AISchemaContext.indices(of: references, in: tables, defaultSchema: "public")) == [1, 2]) + } + } + + @Test("A qualified name leaves another schema's table out") + func qualifiedNameStaysInItsSchema() { + let tables = [ + TestFixtures.makeTableInfo(name: "orders", schema: "archive"), + TestFixtures.makeTableInfo(name: "orders", schema: "public") + ] + let references = AISchemaContext.referencedTables(in: "ALTER TABLE archive.orders ADD due DATE", grammar: Self.grammar) + #expect(AISchemaContext.indices(of: references, in: tables, defaultSchema: "public") == [0]) + } + + @Test("A query that ends in a comment still puts its tables first") + func queryEndingInCommentCounts() { + #expect(Self.names(question: "", query: "SELECT * FROM invoices -- unpaid ones") == ["invoices"]) + } + + @Test("Tables past the budget are counted, not named") + func hiddenTablesAreCounted() { + let section = AISchemaContext.buildSchemaSection( + tables: Self.tables.map { AISchemaTable(table: $0) }, + defaultSchema: nil, + maxTables: 2, + identifierQuote: "\"" + ) + #expect(section.hasSuffix("… and 3 more tables (not shown)")) + #expect(!section.contains("\"invoices\"")) + } +} diff --git a/apple/TableProTests/Core/AI/SchemaContextForAITests.swift b/apple/TableProTests/Core/AI/SchemaContextForAITests.swift index 4056ac3db7..838ccd5c89 100644 --- a/apple/TableProTests/Core/AI/SchemaContextForAITests.swift +++ b/apple/TableProTests/Core/AI/SchemaContextForAITests.swift @@ -162,6 +162,73 @@ struct SchemaContextForAITests { #expect(listed["hr.orders"] == ["salary"]) } + @Test("the tables the statement at the cursor reads take the column budget") + func cursorStatementTablesTakeTheBudget() async throws { + let tables = [ + TestFixtures.makeTableInfo(name: "audit_log"), + TestFixtures.makeTableInfo(name: "orders") + ] + let provider = Self.provider( + tables: tables, + columns: [ + "audit_log": [TestFixtures.makeColumnInfo(name: "message")], + "orders": [TestFixtures.makeColumnInfo(name: "total")] + ] + ) + await provider.resetForDatabase( + "shop", + tables: tables, + driver: MockDatabaseDriver(), + connection: TestFixtures.makeConnection() + ) + var settings = AISettings.default + settings.maxSchemaTables = 1 + let editorText = "SELECT 1;\nSELECT * FROM orders WHERE " + + let context = try #require(await provider.buildSchemaContextForAI( + settings: settings, + editorText: editorText, + cursorOffset: (editorText as NSString).length + )) + let listed = Self.columnNamesByTableLine(in: context) + + #expect(listed["orders"] == ["total"]) + #expect(listed["audit_log"] == nil) + } + + @Test("a table named past the first 2,000 characters of a long statement still takes the budget") + func longStatementTableTakesTheBudget() async throws { + let tables = [ + TestFixtures.makeTableInfo(name: "audit_log"), + TestFixtures.makeTableInfo(name: "orders") + ] + let provider = Self.provider( + tables: tables, + columns: [ + "audit_log": [TestFixtures.makeColumnInfo(name: "message")], + "orders": [TestFixtures.makeColumnInfo(name: "total")] + ] + ) + await provider.resetForDatabase( + "shop", + tables: tables, + driver: MockDatabaseDriver(), + connection: TestFixtures.makeConnection() + ) + var settings = AISettings.default + settings.maxSchemaTables = 1 + let columnList = (0..<500).map { "c\($0)" }.joined(separator: ", ") + let editorText = "SELECT \(columnList) FROM orders WHERE " + + let context = try #require(await provider.buildSchemaContextForAI( + settings: settings, + editorText: editorText, + cursorOffset: (editorText as NSString).length + )) + + #expect(Self.columnNamesByTableLine(in: context)["orders"] == ["total"]) + } + private static func schemaAwareProvider( columnsBySchema: [String: [ColumnInfo]], bulkColumns: [String: [ColumnInfo]] = [:] diff --git a/apple/TableProTests/ViewModels/AIChatViewModelSlashTests.swift b/apple/TableProTests/ViewModels/AIChatViewModelSlashTests.swift index 346754329c..f9aa71cb7a 100644 --- a/apple/TableProTests/ViewModels/AIChatViewModelSlashTests.swift +++ b/apple/TableProTests/ViewModels/AIChatViewModelSlashTests.swift @@ -97,4 +97,34 @@ struct AIChatViewModelSlashTests { #expect(vm.inputText.isEmpty) #expect(vm.errorMessage == nil) } + + @Test("A custom command's turn ranks tables by its body, not by the schema it rendered") + func customCommandRanksByBody() { + let rendered = ChatTurn(role: .user, blocks: [.text("Schema:\n- customers\n - customer_id\nlist the logs")]) + let messages = [ChatTurn(role: .user, blocks: [.text("/report logs")]), rendered] + + #expect(AIChatViewModel.schemaQuestion(in: messages, override: (rendered.id, "logs")) == "logs") + #expect(AIChatViewModel.schemaQuestion(in: messages, override: nil) == rendered.plainText) + } + + @Test("A custom command's body does not rank a later message") + func staleOverrideIsIgnored() { + let earlier = ChatTurn(role: .user, blocks: [.text("rendered")]) + let later = ChatTurn(role: .user, blocks: [.text("how many orders")]) + + #expect(AIChatViewModel.schemaQuestion(in: [earlier, later], override: (earlier.id, "logs")) == "how many orders") + } + + @Test("A tool result turn does not replace the user's question") + func toolResultTurnIsSkipped() { + let question = ChatTurn(role: .user, blocks: [.text("how many orders")]) + let toolResult = ChatTurn( + role: .user, + blocks: [.toolResult(ToolResultBlock(toolUseId: "call_1", content: "rows", isError: false))] + ) + let messages = [question, ChatTurn(role: .assistant, blocks: []), toolResult] + + #expect(AIChatViewModel.schemaQuestion(in: messages, override: nil) == "how many orders") + #expect(AIChatViewModel.schemaQuestion(in: messages, override: (question.id, "orders")) == "orders") + } } diff --git a/apple/changelog.d/3750.changed.md b/apple/changelog.d/3750.changed.md new file mode 100644 index 0000000000..938adc0601 --- /dev/null +++ b/apple/changelog.d/3750.changed.md @@ -0,0 +1 @@ +AI chat and inline suggestions send columns for the tables your question or query uses, not the first tables listed.