From 6a68969ca32ed23958680f2dde90c8335999a745 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:51:11 +0700 Subject: [PATCH 1/2] feat(ios): suggest tables, columns and keywords in the query editor --- apple/.swiftlint.yml | 2 +- apple/CONTRIBUTING.md | 2 +- apple/Packages/TableProCore/Package.swift | 14 + .../DerivedTableParser.swift | 0 .../KeywordUppercaseHelper.swift | 0 .../Mongo/MongoContextAnalyzer.swift | 0 .../Mongo/MongoVocabulary.swift | 0 .../SQLCompletionCasing.swift | 0 .../SQLCompletionCatalog.swift | 16 + .../SQLCompletionInsertion.swift | 0 .../SQLCompletionItem.swift | 0 .../SQLCompletionProvider.swift | 247 ++-- .../SQLCompletionTriggerPolicy.swift | 34 + .../SQLCompletionVocabulary.swift | 51 + .../SQLCompletionWindow.swift | 13 + .../SQLContextAnalyzer.swift | 1177 ++++++++++++++++ .../TableProAutocomplete/SQLKeywords.swift | 24 +- .../SQLSnippetMarker.swift | 0 .../SQLTokenBoundary.swift | 131 ++ .../DerivedTableParserTests.swift | 0 .../KeywordUppercaseHelperTests.swift | 0 .../SQLClauseDetectionTests.swift | 0 .../SQLCompletionInsertionTests.swift | 0 .../SQLCompletionProviderCatalogTests.swift | 86 ++ .../SQLCompletionWindowTests.swift | 35 + .../SQLContextAnalyzer+ANSI.swift | 0 ...LContextAnalyzerCaseInsensitiveTests.swift | 0 .../SQLContextAnalyzerLexicalTests.swift | 192 +++ ...LContextAnalyzerQuotedReferenceTests.swift | 161 +++ .../SQLContextAnalyzerTests.swift | 86 +- .../SQLContextAnalyzerWindowingTests.swift | 0 .../SQLKeywordsTests.swift | 12 +- .../SQLLexicalGrammar+Engine.swift | 7 + .../SQLSnippetMarkerTests.swift | 0 .../SQLTokenBoundaryTests.swift | 57 +- apple/Packages/TableProMacCore/Package.swift | 18 - .../SQLCompletionTriggerPolicy.swift | 30 - .../SQLContextAnalyzer.swift | 1196 ----------------- .../SQLTokenBoundary.swift | 82 -- .../SQLCompletionProvider+App.swift | 47 + .../Autocomplete/SQLCompletionService.swift | 44 +- .../SQLSchemaProvider+CompletionCatalog.swift | 3 + .../TablePro/Resources/Localizable.xcstrings | 4 + .../CompletionIdentifierQuoting.swift | 84 ++ .../Completion/CompletionReservedWords.swift | 125 ++ .../Completion/QueryCompletionCatalog.swift | 232 ++++ .../QueryCompletionController.swift | 350 +++++ .../TableProMobile/Localizable.xcstrings | 24 + .../Views/Components/QuerySuggestionBar.swift | 108 ++ .../Views/Components/SQLEditorTextView.swift | 46 + .../Components/SQLHighlightTextView.swift | 89 +- .../Views/QueryEditorView.swift | 19 +- .../CompletionIdentifierQuotingTests.swift | 116 ++ .../QueryCompletionCatalogTests.swift | 329 +++++ .../QueryCompletionControllerTests.swift | 496 +++++++ .../SQLEditorTextViewKeyCommandTests.swift | 157 +++ apple/TableProMobile/project.yml | 1 + .../SQLCompletionServiceTokenStartTests.swift | 32 + .../SQLCompletionAdapterFuzzyTests.swift | 27 +- apple/TestPlans/Unit.xctestplan | 7 - apple/project.yml | 4 +- 61 files changed, 4335 insertions(+), 1682 deletions(-) rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/DerivedTableParser.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/KeywordUppercaseHelper.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/Mongo/MongoContextAnalyzer.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/Mongo/MongoVocabulary.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/SQLCompletionCasing.swift (100%) create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/SQLCompletionInsertion.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/SQLCompletionItem.swift (100%) rename apple/{TablePro/Core/Autocomplete => Packages/TableProCore/Sources/TableProAutocomplete}/SQLCompletionProvider.swift (68%) create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionVocabulary.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionWindow.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/SQLKeywords.swift (96%) rename apple/Packages/{TableProMacCore => TableProCore}/Sources/TableProAutocomplete/SQLSnippetMarker.swift (100%) create mode 100644 apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLCompletionInsertionTests.swift (100%) create mode 100644 apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionWindowTests.swift rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLContextAnalyzer+ANSI.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift (100%) create mode 100644 apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerLexicalTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift (93%) rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLContextAnalyzerWindowingTests.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLKeywordsTests.swift (95%) create mode 100644 apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLLexicalGrammar+Engine.swift rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift (100%) rename apple/Packages/{TableProMacCore => TableProCore}/Tests/TableProAutocompleteTests/SQLTokenBoundaryTests.swift (74%) delete mode 100644 apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift delete mode 100644 apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift delete mode 100644 apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift create mode 100644 apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift create mode 100644 apple/TablePro/Core/Autocomplete/SQLSchemaProvider+CompletionCatalog.swift create mode 100644 apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift create mode 100644 apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift create mode 100644 apple/TableProMobile/TableProMobile/Completion/QueryCompletionCatalog.swift create mode 100644 apple/TableProMobile/TableProMobile/Completion/QueryCompletionController.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/Components/QuerySuggestionBar.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/Components/SQLEditorTextView.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionCatalogTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionControllerTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Views/SQLEditorTextViewKeyCommandTests.swift create mode 100644 apple/TableProTests/Core/Autocomplete/SQLCompletionServiceTokenStartTests.swift diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml index b8713e4a97..a8762c3dc6 100644 --- a/apple/.swiftlint.yml +++ b/apple/.swiftlint.yml @@ -579,7 +579,7 @@ custom_rules: # Tests (listed in TableProTests/.swiftlint.yml and Plugins/.swiftlint.yml) suite_without_trait: name: "Suite Without A Trait" - included: ['.*/(TableProTests|Plugins/[A-Za-z]+Tests|Packages/TableProMacCore/Tests)/'] + included: ['.*/(TableProTests|Plugins/[A-Za-z]+Tests|Packages/TableProMacCore/Tests|Packages/TableProCore/Tests/TableProAutocompleteTests)/'] regex: '(?:^#if\b[^\n]*\n[ \t]*|^)(?:@\w+[ \t]+)*@(?:Testing\.)?Suite(?:[ \t]*\(\s*(?:"(?:[^"\\\n]|\\.)*"|#+"[^\n]*?"#+)?\s*\))?(?![ \t]*\()(?=\s)' excluded_match_kinds: [comment, doccomment] message: "Every top-level @Suite expansion walks every other one, so compile time grows with their square. Keep @Suite only to carry a trait such as .serialized." diff --git a/apple/CONTRIBUTING.md b/apple/CONTRIBUTING.md index 088f974c2e..818d2b8a0b 100644 --- a/apple/CONTRIBUTING.md +++ b/apple/CONTRIBUTING.md @@ -107,7 +107,7 @@ The architecture is in the area rules under `.claude/rules/`, and its invariants | TableProEditor | `Packages/`, vendored from CodeEditSourceEditor and CodeEditTextView | The SQL editor | | TableProGrammars | `Packages/` | Tree-sitter grammars | | TableProCore | `Packages/` | Modules the macOS and iOS apps share | -| TableProMacCore | `Packages/` | macOS-only modules the app links: utilities, autocomplete, SSH config, MCP | +| TableProMacCore | `Packages/` | macOS-only modules the app links: utilities, SSH config, MCP | | TableProOracle | `Packages/`, wraps a pinned fork of OracleNIO | Oracle wire protocol | | Sparkle | SPM | Updates, EdDSA-signed | | Yams | SPM | YAML for project folder import | diff --git a/apple/Packages/TableProCore/Package.swift b/apple/Packages/TableProCore/Package.swift index 668794c9fb..b1cfe4df04 100644 --- a/apple/Packages/TableProCore/Package.swift +++ b/apple/Packages/TableProCore/Package.swift @@ -44,6 +44,7 @@ let package = Package( .library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"]), .library(name: "TableProConnectionLibrary", targets: ["TableProConnectionLibrary"]), .library(name: "TableProSQLGrammar", targets: ["TableProSQLGrammar"]), + .library(name: "TableProAutocomplete", targets: ["TableProAutocomplete"]), .library(name: "TableProSSHTransport", targets: ["TableProSSHTransport"]), .library(name: "CSQLite", targets: ["CSQLite"]), .library(name: "TableProSQLiteCore", targets: ["TableProSQLiteCore"]), @@ -200,6 +201,13 @@ let package = Package( dependencies: [], path: "Sources/TableProSQLGrammar" ), + // The macOS app links it, so never TableProPluginKit. + .target( + name: "TableProAutocomplete", + dependencies: ["TableProSQLGrammar"], + path: "Sources/TableProAutocomplete", + swiftSettings: pluginCoreSettings + ), .target( name: "TableProSSHTransport", dependencies: [], @@ -329,6 +337,12 @@ let package = Package( dependencies: ["TableProSQLGrammar"], path: "Tests/TableProSQLGrammarTests" ), + .testTarget( + name: "TableProAutocompleteTests", + dependencies: ["TableProAutocomplete", "TableProSQLGrammar"], + path: "Tests/TableProAutocompleteTests", + swiftSettings: pluginCoreSettings + ), .testTarget( name: "TableProAnalyticsTests", dependencies: ["TableProAnalytics"], diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/DerivedTableParser.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/DerivedTableParser.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/DerivedTableParser.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/DerivedTableParser.swift diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/KeywordUppercaseHelper.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/KeywordUppercaseHelper.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/KeywordUppercaseHelper.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/KeywordUppercaseHelper.swift diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/Mongo/MongoContextAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/Mongo/MongoContextAnalyzer.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/Mongo/MongoContextAnalyzer.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/Mongo/MongoContextAnalyzer.swift diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/Mongo/MongoVocabulary.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/Mongo/MongoVocabulary.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/Mongo/MongoVocabulary.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/Mongo/MongoVocabulary.swift diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionCasing.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCasing.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionCasing.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCasing.swift diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift new file mode 100644 index 0000000000..cbce84c447 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionCatalog.swift @@ -0,0 +1,16 @@ +import Foundation + +// Each app answers from its own schema cache, so both rank through one provider. +public protocol SQLCompletionCatalog: Sendable { + func resolveAlias(_ aliasOrName: String, in references: [TableReference]) async -> String? + func columnCompletionItems(for tableName: String, schema: String?) async -> [SQLCompletionItem] + func tableCompletionItems() async -> [SQLCompletionItem] + func tableCompletionItems(inSchema schema: String) async -> [SQLCompletionItem] + func schemaCompletionItems() async -> [SQLCompletionItem] + func namespaceCompletionItems() async -> [SQLCompletionItem] + func isKnownSchema(_ name: String) async -> Bool + func isKnownDatabase(_ name: String) async -> Bool + func allowedValues(forColumn column: String, in references: [TableReference]) async -> [String] + func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] + func allColumnsFromCachedTables() async -> [SQLCompletionItem] +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionInsertion.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionInsertion.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionInsertion.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionInsertion.swift diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionItem.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionItem.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionItem.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionItem.swift diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift similarity index 68% rename from apple/TablePro/Core/Autocomplete/SQLCompletionProvider.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift index 2e1d23c9bf..51853f29c3 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift @@ -1,34 +1,16 @@ -// -// SQLCompletionProvider.swift -// TablePro -// -// Main orchestrator for SQL autocomplete -// - import Foundation -import TableProAutocomplete -import TableProPluginKit import TableProSQLGrammar -/// Main provider for SQL autocomplete suggestions -final class SQLCompletionProvider { - // MARK: - Properties - +public final class SQLCompletionProvider { private let contextAnalyzer = SQLContextAnalyzer() - private let schemaProvider: SQLSchemaProvider? - private var databaseType: DatabaseType? - private var cachedDialect: SQLDialectDescriptor? + private let catalog: (any SQLCompletionCatalog)? + private var grammar: SQLLexicalGrammar + private var vocabulary: SQLCompletionVocabulary private var cachedFunctionItems: [SQLCompletionItem]? - private var cachedStatementCompletions: [CompletionEntry] = [] private var favoriteKeywords: [String: (name: String, query: String)] = [:] - /// Minimum prefix length to trigger suggestions - private let minPrefixLength = 1 - - /// Default maximum number of suggestions to return private let defaultMaxSuggestions = 20 - /// Context-aware suggestion limit: schema-heavy clauses get more results private func maxSuggestions(for clauseType: SQLClauseType) -> Int { switch clauseType { case .from, .join, .into, .dropObject, .createIndex, @@ -40,36 +22,25 @@ final class SQLCompletionProvider { } } - // MARK: - Init - - init(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, - dialect: SQLDialectDescriptor? = nil, statementCompletions: [CompletionEntry] = []) { - self.schemaProvider = schemaProvider - self.databaseType = databaseType - self.cachedDialect = dialect - self.cachedStatementCompletions = statementCompletions + public init(catalog: (any SQLCompletionCatalog)?, grammar: SQLLexicalGrammar, vocabulary: SQLCompletionVocabulary) { + self.catalog = catalog + self.grammar = grammar + self.vocabulary = vocabulary } - /// Update the database type for context-aware completions - func setDatabaseType(_ type: DatabaseType, dialect: SQLDialectDescriptor? = nil, statementCompletions: [CompletionEntry] = []) { - self.databaseType = type - self.cachedDialect = dialect + public func update(grammar: SQLLexicalGrammar, vocabulary: SQLCompletionVocabulary) { + self.grammar = grammar + self.vocabulary = vocabulary self.cachedFunctionItems = nil - self.cachedStatementCompletions = statementCompletions } - /// Update cached favorite keywords for autocomplete expansion - func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { + public func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { self.favoriteKeywords = keywords } - // MARK: - Public API - - /// Get completion suggestions for the current cursor position. - /// `forcedTableReferences` overrides the tables in scope, used when the caller - /// already knows the table (e.g. a single-table filter expression) rather than - /// relying on a FROM clause in the analyzed text. - func getCompletions( + // `forcedTableReferences` stands in for the FROM clause when the caller already knows the table, as a + // single-table filter expression does. + public func getCompletions( text: String, cursorPosition: Int, forcedTableReferences: [TableReference]? = nil @@ -82,16 +53,13 @@ final class SQLCompletionProvider { return (session.items, session.context) } - /// The completions for the cursor position, as both what the popup shows and the wider pool - /// it re-ranks against while it stays open. - func completionSession( + // `items` is what the popup shows; `candidates` is the wider pool it re-ranks while it stays open. + public func completionSession( text: String, cursorPosition: Int, forcedTableReferences: [TableReference]? = nil ) async -> (items: [SQLCompletionItem], candidates: [SQLCompletionItem], context: SQLContext) { - var context = contextAnalyzer.analyze( - query: text, cursorPosition: cursorPosition, grammar: databaseType?.lexicalGrammar ?? .ansi - ) + var context = contextAnalyzer.analyze(query: text, cursorPosition: cursorPosition, grammar: grammar) if let forcedTableReferences { context = context.replacingTableReferences(forcedTableReferences) } @@ -113,14 +81,9 @@ final class SQLCompletionProvider { return (Array(candidates.prefix(limit)), Array(candidates.prefix(sessionPool(for: limit))), context) } - /// Filter, rank and cut a session's candidates down to what the popup shows. - /// - /// The popup shows `maxSuggestions`, but the session holds `sessionPool` of them, because an - /// open popup re-ranks against what it kept rather than asking again. A candidate cut at the - /// opening prefix can never lead for a longer one however well the survivors are ordered, and - /// PostgreSQL declares more `T`-prefixed functions than the popup shows rows, every one of - /// them outranking the `TRUE` keyword for the single letter `t`. - func filterRankAndLimit( + // A candidate cut at the opening prefix can never lead for a longer one, and PostgreSQL declares more `T` + // functions than the popup shows rows, every one outranking `TRUE` for the letter `t`. + public func filterRankAndLimit( _ items: [SQLCompletionItem], prefix: String, context: SQLContext @@ -128,21 +91,17 @@ final class SQLCompletionProvider { Array(filterAndRank(items, prefix: prefix, context: context).prefix(maxSuggestions(for: context.clauseType))) } - /// Ten times what the popup shows. Ranking is linear in the pool and the filter beside it - /// already walks every candidate: measured at 36us for 40 candidates and 340us for 400, against - /// 4ms at 5,000, which is why the pool is bounded rather than kept whole. + // Ranking is linear in the pool: measured 36us for 40 candidates, 340us for 400 and 4ms for 5,000. private func sessionPool(for limit: Int) -> Int { limit * 10 } - /// Generic SQL functions plus the active dialect's own functions (deduplicated). - /// Cached per dialect; invalidated in `setDatabaseType`. private func functionItems() -> [SQLCompletionItem] { if let cachedFunctionItems { return cachedFunctionItems } var items = SQLKeywords.functionItems() - if let dialect = cachedDialect, !dialect.functions.isEmpty { + if !vocabulary.functions.isEmpty { let folding: SQLCompletionCaseFolding = - dialect.functionNamesAreCaseInsensitive ? .caseInsensitive : .fixed + vocabulary.functionNamesAreCaseInsensitive ? .caseInsensitive : .fixed var seen = Set(items.map { $0.label.uppercased() }) - for name in dialect.functions.sorted() where seen.insert(name.uppercased()).inserted { + for name in vocabulary.functions.sorted() where seen.insert(name.uppercased()).inserted { items.append(SQLCompletionItem.function(name, signature: "\(name)(…)", caseFolding: folding)) } } @@ -152,34 +111,31 @@ final class SQLCompletionProvider { // MARK: - Candidate Generation - /// Get candidate completions based on context private func getCandidates( // swiftlint:disable:this function_body_length for context: SQLContext ) async -> [SQLCompletionItem] { var items: [SQLCompletionItem] = [] - // If we have a dot prefix, resolve it as table/alias columns first, then as - // a schema (suggest its tables) or database (suggest its schemas). The - // namespace fallback also covers aliases that spuriously resolve to a - // schema name parsed out of the FROM clause itself. + // A dot prefix is a table or alias first, then a schema (its tables) or a database (its schemas). The + // namespace fallback also covers an alias that resolves to a schema name parsed out of the FROM clause. if let dotPrefix = context.dotPrefix { - guard let schemaProvider else { return [] } + guard let catalog else { return [] } if let derived = context.tableReferences.first(where: { $0.isDerived && $0.identifier.caseInsensitiveCompare(dotPrefix) == .orderedSame }), let columns = derived.derivedColumns, !columns.isEmpty { return columns.map { SQLCompletionItem.column($0, dataType: nil, tableName: derived.identifier) } } - if let tableName = await schemaProvider.resolveAlias(dotPrefix, in: context.tableReferences) { + if let tableName = await catalog.resolveAlias(dotPrefix, in: context.tableReferences) { let schema = context.tableReferences.first { $0.tableName.caseInsensitiveCompare(tableName) == .orderedSame }?.schema - items = await schemaProvider.columnCompletionItems(for: tableName, schema: schema) + items = await catalog.columnCompletionItems(for: tableName, schema: schema) } if items.isEmpty { - if await schemaProvider.isKnownSchema(dotPrefix) { - items = await schemaProvider.tableCompletionItems(inSchema: dotPrefix) - } else if await schemaProvider.isKnownDatabase(dotPrefix) { - items = await schemaProvider.schemaCompletionItems() + if await catalog.isKnownSchema(dotPrefix) { + items = await catalog.tableCompletionItems(inSchema: dotPrefix) + } else if await catalog.isKnownDatabase(dotPrefix) { + items = await catalog.schemaCompletionItems() } } return items @@ -187,8 +143,8 @@ final class SQLCompletionProvider { switch context.clauseType { case .from, .join: - items = await schemaProvider?.tableCompletionItems() ?? [] - items += await schemaProvider?.namespaceCompletionItems() ?? [] + items = await catalog?.tableCompletionItems() ?? [] + items += await catalog?.namespaceCompletionItems() ?? [] items += filterKeywords([ "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN", @@ -198,7 +154,7 @@ final class SQLCompletionProvider { ]) case .into: - items = await schemaProvider?.tableCompletionItems() ?? [] + items = await catalog?.tableCompletionItems() ?? [] items += filterKeywords([ "VALUES", "SELECT", "SET", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", @@ -212,7 +168,6 @@ final class SQLCompletionProvider { if let funcName = context.currentFunction { let upperFunc = funcName.uppercased() if upperFunc == "COUNT" { - // COUNT() special: suggest * and DISTINCT as top items var starItem = SQLCompletionItem( label: "*", kind: .keyword, @@ -240,7 +195,6 @@ final class SQLCompletionProvider { detail: "All columns", sortPriority: 50 )) - // table.* suggestions when multiple tables in scope (HP-5) for ref in context.tableReferences { let qualifier = ref.alias ?? ref.tableName items.append(SQLCompletionItem( @@ -261,11 +215,10 @@ final class SQLCompletionProvider { case .on: items += await allowedValueItems(for: context) - // HP-3: ON clause — prioritize columns from joined tables items += await columnItems(for: context.tableReferences) for ref in context.tableReferences { let qualifier = ref.alias ?? ref.tableName - let cols = await schemaProvider?.columnCompletionItems(for: ref.tableName, schema: ref.schema) ?? [] + let cols = await catalog?.columnCompletionItems(for: ref.tableName, schema: ref.schema) ?? [] for col in cols { items.append(SQLCompletionItem( label: "\(qualifier).\(col.label)", @@ -282,9 +235,7 @@ final class SQLCompletionProvider { items += filterKeywords([ "AND", "OR", "NOT", "IS", "NULL", "TRUE", "FALSE" ]) - // Continuations once the join condition is written: another join or - // the next clause. Without these, typing the next keyword (e.g. a - // second INNER JOIN) only fuzzy-matches columns. + // Once the join condition is written, the next keyword would otherwise only fuzzy-match columns. items += filterKeywords([ "INNER JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN", @@ -294,7 +245,6 @@ final class SQLCompletionProvider { ]) case .where_, .and, .having: - // HP-8: Columns, operators, logical keywords + clause transitions items += await allowedValueItems(for: context) items += await columnItems(for: context.tableReferences) items += SQLKeywords.operatorItems() @@ -328,13 +278,13 @@ final class SQLCompletionProvider { case .set: if let firstTable = context.tableReferences.first { - items = await schemaProvider?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] + items = await catalog?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] } items += filterKeywords(["WHERE", "RETURNING"]) case .insertColumns: if let firstTable = context.tableReferences.first { - items = await schemaProvider?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] + items = await catalog?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] } case .values: @@ -347,7 +297,6 @@ final class SQLCompletionProvider { case .functionArg: let isCountFunction = context.currentFunction?.uppercased() == "COUNT" if isCountFunction { - // COUNT() special: suggest * as top item var starItem = SQLCompletionItem( label: "*", kind: .keyword, @@ -355,9 +304,8 @@ final class SQLCompletionProvider { detail: String(localized: "All columns"), documentation: String(localized: "Count all rows") ) - starItem.sortPriority = 10 // Highest priority + starItem.sortPriority = 10 items.append(starItem) - // Boost DISTINCT for COUNT(DISTINCT ...) var distinctItem = SQLCompletionItem.keyword("DISTINCT") distinctItem.sortPriority = 20 items.append(distinctItem) @@ -365,7 +313,6 @@ final class SQLCompletionProvider { items += await columnItems(for: context.tableReferences) items += functionItems() if isCountFunction { - // DISTINCT already added above with boosted priority items += filterKeywords(["NULL", "TRUE", "FALSE"]) } else { items += filterKeywords(["NULL", "TRUE", "FALSE", "DISTINCT"]) @@ -384,7 +331,6 @@ final class SQLCompletionProvider { items += functionItems() case .limit: - // After LIMIT/OFFSET - typically just numbers, but could include variables items += filterKeywords(["OFFSET", "FETCH", "NEXT", "ROWS", "ONLY"]) case .alterTable: @@ -398,12 +344,12 @@ final class SQLCompletionProvider { case .alterTableColumn: if let firstTable = context.tableReferences.first { - items = await schemaProvider?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] + items = await catalog?.columnCompletionItems(for: firstTable.tableName, schema: firstTable.schema) ?? [] } case .createTable: if context.nestingLevel >= 1 { - // Boost FK-related keywords so they appear within the 20-item limit + // Boosted so the foreign key keywords fit inside the 20-item limit. items = boostedKeywords([ "REFERENCES", "ON DELETE", "ON UPDATE", "CASCADE", "RESTRICT", "SET NULL", "NO ACTION", @@ -417,7 +363,7 @@ final class SQLCompletionProvider { items += dataTypeKeywords() } else { items = filterKeywords(["IF NOT EXISTS"]) - if let options = cachedDialect?.tableOptions { + if let options = vocabulary.tableOptions { items += filterKeywords(options) } else { items += filterKeywords([ @@ -455,12 +401,12 @@ final class SQLCompletionProvider { ]) case .dropObject: - items = await schemaProvider?.tableCompletionItems() ?? [] + items = await catalog?.tableCompletionItems() ?? [] items += filterKeywords(["IF EXISTS", "CASCADE", "RESTRICT"]) case .createIndex: if context.tableReferences.isEmpty { - items = await schemaProvider?.tableCompletionItems() ?? [] + items = await catalog?.tableCompletionItems() ?? [] items += filterKeywords(["ON"]) } else { items = await columnItems(for: context.tableReferences) @@ -469,14 +415,14 @@ final class SQLCompletionProvider { case .createView: items = filterKeywords(["SELECT", "AS"]) - items += await schemaProvider?.tableCompletionItems() ?? [] + items += await catalog?.tableCompletionItems() ?? [] case .castTarget: items = castTargetCompletionItems() case .unknown: items = statementStartCompletionItems() - items += await schemaProvider?.tableCompletionItems() ?? [] + items += await catalog?.tableCompletionItems() ?? [] } items += favoriteCompletions(matching: context.prefix) @@ -493,12 +439,10 @@ final class SQLCompletionProvider { .map { SQLCompletionItem.favorite(keyword: $0.key, name: $0.value.name, query: $0.value.query) } } - /// Values a compared column is restricted to, offered as quoted literals ahead of everything - /// else. Nothing is offered for an ordinary column. private func allowedValueItems(for context: SQLContext) async -> [SQLCompletionItem] { - guard let column = context.comparisonColumn, let schemaProvider else { return [] } + guard let column = context.comparisonColumn, let catalog else { return [] } - let values = await schemaProvider.allowedValues(forColumn: column, in: context.tableReferences) + let values = await catalog.allowedValues(forColumn: column, in: context.tableReferences) return values.map { value in var item = SQLCompletionItem( label: "'\(value)'", @@ -512,14 +456,9 @@ final class SQLCompletionProvider { } } - /// Operators the connected dialect declares, with their documented meaning. - /// - /// Case-insensitive like any other keyword, because the list is not only symbols: PostgreSQL - /// declares `IS DISTINCT FROM`, `IS NOT NULL` and `BETWEEN SYMMETRIC` here, and the same words - /// arrive from `SQLKeywords` as well. A symbol has no cased character, so folding leaves it be. + // Case-insensitive like any keyword: PostgreSQL declares `IS DISTINCT FROM` and `BETWEEN SYMMETRIC` here too. private func dialectOperatorItems() -> [SQLCompletionItem] { - guard let descriptor = cachedDialect else { return [] } - return descriptor.operators.map { operatorDescriptor in + vocabulary.operators.map { operatorDescriptor in SQLCompletionItem( label: operatorDescriptor.symbol, kind: .operator, @@ -533,13 +472,10 @@ final class SQLCompletionProvider { } } - /// Type names offered directly after a `::` cast operator, in the spelling users write. - /// - /// Lower case and fixed, which is the convention for a cast and is not the one `CREATE TABLE` - /// uses. The two spellings of one vocabulary are why this stays out of the keyword case policy. + // Lower case and fixed after `::`, the cast convention, unlike the spelling CREATE TABLE offers. private func castTargetCompletionItems() -> [SQLCompletionItem] { - guard let descriptor = cachedDialect, !descriptor.dataTypes.isEmpty else { return [] } - return descriptor.dataTypes.sorted().map { typeName in + guard !vocabulary.dataTypes.isEmpty else { return [] } + return vocabulary.dataTypes.sorted().map { typeName in let lowercased = typeName.lowercased() var item = SQLCompletionItem(label: lowercased, kind: .keyword, insertText: lowercased) item.sortPriority = 300 @@ -547,12 +483,10 @@ final class SQLCompletionProvider { } } - /// SQL data type keywords (database-aware), with a slight priority boost - /// so they sort before generic constraint keywords in CREATE TABLE context. - /// Uses plugin-provided dialect data when available; falls back to common SQL types. + // Boosted so data types sort before the generic constraint keywords in CREATE TABLE. private func dataTypeKeywords() -> [SQLCompletionItem] { - if let descriptor = cachedDialect, !descriptor.dataTypes.isEmpty { - return descriptor.dataTypes.sorted().map { typeName in + if !vocabulary.dataTypes.isEmpty { + return vocabulary.dataTypes.sorted().map { typeName in var item = SQLCompletionItem( label: typeName, kind: .keyword, @@ -579,15 +513,13 @@ final class SQLCompletionProvider { } } - /// Columns from explicit table references, or all cached schema columns as fallback private func columnItems(for references: [TableReference]) async -> [SQLCompletionItem] { if references.isEmpty { - return await schemaProvider?.allColumnsFromCachedTables() ?? [] + return await catalog?.allColumnsFromCachedTables() ?? [] } - return await schemaProvider?.allColumnsInScope(for: references) ?? [] + return await catalog?.allColumnsInScope(for: references) ?? [] } - /// Filter to specific keywords private func filterKeywords(_ keywords: [String]) -> [SQLCompletionItem] { keywords.map { SQLCompletionItem.keyword($0) } } @@ -602,9 +534,9 @@ final class SQLCompletionProvider { "CALL", "EXECUTE", "PREPARE" ] - func statementStartCompletionItems() -> [SQLCompletionItem] { - guard cachedStatementCompletions.isEmpty else { - return cachedStatementCompletions.map { entry in + public func statementStartCompletionItems() -> [SQLCompletionItem] { + guard vocabulary.statementCompletions.isEmpty else { + return vocabulary.statementCompletions.map { entry in SQLCompletionItem( label: entry.label, kind: .keyword, @@ -615,7 +547,6 @@ final class SQLCompletionProvider { return filterKeywords(Self.statementStartKeywords) } - /// Create keyword items with boosted (lower) sort priority private func boostedKeywords(_ keywords: [String], priority: Int) -> [SQLCompletionItem] { keywords.map { kw in var item = SQLCompletionItem.keyword(kw) @@ -626,17 +557,14 @@ final class SQLCompletionProvider { // MARK: - Filtering - /// Filter and rank items by prefix, returning sorted results with match ranges - func filterAndRank(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { + public func filterAndRank(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { let filtered = filterByPrefix(items, prefix: prefix) return rankResults(filtered, prefix: prefix, context: context) } - /// Filter candidates by prefix (case-insensitive) with fuzzy matching support. - /// Resolves `matchedRanges` and the fuzzy-only `fuzzyPenalty` in one pass per - /// candidate so `rankResults` never recomputes a fuzzy match. Both fields are - /// assigned (never accumulated), so re-filtering a prior result is idempotent. - func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { + // Resolves `matchedRanges` and the fuzzy-only `fuzzyPenalty` once per candidate, so ranking never matches again. + // Both are assigned rather than accumulated, so filtering a filtered list is idempotent. + public func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { guard !prefix.isEmpty else { var reset = items for i in reset.indices { @@ -674,18 +602,12 @@ final class SQLCompletionProvider { return kept } - /// NSString.range(of:) without the anchored option, returning a Swift Range - /// or nil when not found. Avoids re-bridging the result through NSNotFound. private func optionalRange(of substring: String, in target: NSString) -> Range? { let range = target.range(of: substring) guard range.location != NSNotFound else { return nil } return range.location..<(range.location + range.length) } - /// Single fuzzy pass that resolves match state, penalty score, and matched - /// character indices in one traversal. `filterByPrefix` calls this once per - /// candidate. Uses NSString character-at-index for O(1) random access instead - /// of Swift String indexing (LP-9). private func resolveFuzzyMatch(pattern: String, target: String) -> (penalty: Int, indices: [Int])? { let nsPattern = pattern as NSString let nsTarget = target as NSString @@ -733,13 +655,11 @@ final class SQLCompletionProvider { return (penalty, matchedIndices) } - /// Fuzzy matching with scoring: returns penalty score (higher = worse), - /// nil = no match. - func fuzzyMatchScore(pattern: String, target: String) -> Int? { + // Higher is worse; nil is no match. + public func fuzzyMatchScore(pattern: String, target: String) -> Int? { resolveFuzzyMatch(pattern: pattern, target: target)?.penalty } - /// Convert sorted individual character indices into contiguous ranges private func indicesToRanges(_ indices: [Int]) -> [Range] { guard !indices.isEmpty else { return [] } var ranges: [Range] = [] @@ -760,16 +680,9 @@ final class SQLCompletionProvider { // MARK: - Ranking - /// Rank results by relevance, lowest score first. - /// - /// Scores are resolved once per candidate rather than inside the comparator, which called - /// `calculateScore` twice per comparison. Ranking runs on every keystroke of an open popup, - /// so the candidate set is walked once and the sort then compares integers. - /// - /// Equal scores fall back to the candidate's own position, which is the order the generator - /// emitted it in and carries meaning `sorted(by:)` would otherwise be free to discard: the - /// standard library documents the sort as not stable. - func rankResults(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { + // Scores once per candidate rather than in the comparator. Equal scores keep the order the generator emitted, + // which carries meaning: the standard library's sort is not stable. + public func rankResults(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { let lowerPrefix = prefix.lowercased() let scored = items.enumerated().map { position, item in (position: position, item: item, score: calculateScore(for: item, prefix: lowerPrefix, context: context)) @@ -780,10 +693,8 @@ final class SQLCompletionProvider { }.map(\.item) } - /// Calculate ranking score for an item (lower = better). - /// The fuzzy-only penalty is precomputed into `fuzzyPenalty` by `filterByPrefix` - /// so the ranking comparator does not invoke fuzzy matching again. - func calculateScore(for item: SQLCompletionItem, prefix: String, context: SQLContext) -> Int { + // Lower is better. + public func calculateScore(for item: SQLCompletionItem, prefix: String, context: SQLContext) -> Int { var score = item.sortPriority + item.fuzzyPenalty if item.filterText.hasPrefix(prefix) { @@ -794,10 +705,8 @@ final class SQLCompletionProvider { score -= 1_000 } - // When prefix is empty and tables are in scope, the user is either in a - // table-operand slot (e.g. "... JOIN |") or at a clause transition point - // (e.g. "FROM users |" or "WHERE id > 1 |"). In the operand slot, tables - // lead; otherwise keywords lead so clause transitions surface. + // With nothing typed and tables in scope, tables lead in an operand slot (`... JOIN |`) and keywords lead at + // a clause transition (`FROM users |`). if prefix.isEmpty && !context.tableReferences.isEmpty && !context.isAfterComma { if context.expectsObjectName { if item.kind == .table || item.kind == .view || item.kind == .schema { @@ -807,7 +716,6 @@ final class SQLCompletionProvider { score -= 300 } } else { - // Context-appropriate bonuses when actively typing switch context.clauseType { case .from, .join, .into, .dropObject, .createIndex: if item.kind == .table || item.kind == .view { @@ -827,7 +735,6 @@ final class SQLCompletionProvider { } } - // Shorter names slightly preferred score += (item.label as NSString).length return score diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift new file mode 100644 index 0000000000..db18b14123 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift @@ -0,0 +1,34 @@ +import Foundation + +public enum SQLCompletionTriggerPolicy { + // A clause with a full column list behind it stays shut until the user types or asks; the listed ones answer with + // a short, closed list. An opening quote counts as typed input: it sits in `prefixRange` but not in `prefix`. + public static func suppressesEmptyPrefix(_ context: SQLContext, isManualTrigger: Bool) -> Bool { + guard !isManualTrigger, + context.prefix.isEmpty, + context.prefixRange.isEmpty, + context.dotPrefix == nil else { return false } + + switch context.clauseType { + case .from, .join, .into, .set, .insertColumns, .on, + .alterTableColumn, .returning, .using, .dropObject, .createIndex, .castTarget: + return false + case .select where !context.isAfterComma: + return false + default: + return true + } + } + + public static func suppressesAfterStatementBreak(in text: NSString, at offset: Int) -> Bool { + guard offset > 0, offset - 1 < text.length else { return false } + + let previous = text.character(at: offset - 1) + let semicolon = UInt16(UnicodeScalar(";").value) + let newline = UInt16(UnicodeScalar("\n").value) + guard previous == semicolon || previous == newline else { return false } + guard offset < text.length else { return true } + + return text.substring(from: offset).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionVocabulary.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionVocabulary.swift new file mode 100644 index 0000000000..c835dde919 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionVocabulary.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct SQLCompletionVocabulary: Sendable { + public struct Operator: Sendable, Hashable { + public let symbol: String + public let summary: String + public let appliesToTypes: [String] + + public init(symbol: String, summary: String, appliesToTypes: [String] = []) { + self.symbol = symbol + self.summary = summary + self.appliesToTypes = appliesToTypes + } + } + + public struct StatementCompletion: Sendable, Hashable { + public let label: String + public let insertText: String + + public init(label: String, insertText: String) { + self.label = label + self.insertText = insertText + } + } + + public let functions: Set + public let functionNamesAreCaseInsensitive: Bool + public let operators: [Operator] + public let dataTypes: Set + // nil offers the generic options; an empty list offers none. + public let tableOptions: [String]? + public let statementCompletions: [StatementCompletion] + + public init( + functions: Set = [], + functionNamesAreCaseInsensitive: Bool = false, + operators: [Operator] = [], + dataTypes: Set = [], + tableOptions: [String]? = nil, + statementCompletions: [StatementCompletion] = [] + ) { + self.functions = functions + self.functionNamesAreCaseInsensitive = functionNamesAreCaseInsensitive + self.operators = operators + self.dataTypes = dataTypes + self.tableOptions = tableOptions + self.statementCompletions = statementCompletions + } + + public static let generic = SQLCompletionVocabulary() +} diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionWindow.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionWindow.swift new file mode 100644 index 0000000000..42fc0c0b37 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionWindow.swift @@ -0,0 +1,13 @@ +import Foundation + +// The analyzer reads only the text around the caret, so a long script costs a keystroke no more than a short one. +public enum SQLCompletionWindow { + public static let radius = 5_000 + + public static func cut(_ text: NSString, around offset: Int) -> (text: String, start: Int) { + let caret = min(max(offset, 0), text.length) + let start = max(0, caret - radius) + let end = min(text.length, caret + radius) + return (text.substring(with: NSRange(location: start, length: end - start)), start) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift new file mode 100644 index 0000000000..ad21bdd111 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift @@ -0,0 +1,1177 @@ +import Foundation +import os +import TableProSQLGrammar + +private let regexLogger = Logger(subsystem: "com.TablePro", category: "SQLContextAnalyzer.Regex") + +private func compileRegex(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression { + if let regex = try? NSRegularExpression(pattern: pattern, options: options) { + return regex + } + regexLogger.fault("Failed to compile static regex pattern: \(pattern, privacy: .public)") + return NSRegularExpression() +} + +public enum SQLClauseType: Sendable { + case select + case from + case join + case on + case where_ + case and + case groupBy + case orderBy + case having + case set + case into + case values + case insertColumns + case functionArg + case caseExpression + case inList + case limit + case alterTable + case alterTableColumn + case createTable + case columnDef + case returning + case union + case using + case window + case dropObject + case createIndex + case createView + case castTarget + case unknown +} + +public struct TableReference: Hashable, Sendable { + public let tableName: String + public let alias: String? + public let schema: String? + // A subquery or CTE carries the columns of its own SELECT list instead of reading the schema. + public let derivedColumns: [String]? + // Whether `identifier` was quoted in the statement, since a qualifier inserted bare would fold to another name. + public let isIdentifierQuoted: Bool + + public init( + tableName: String, + alias: String?, + schema: String? = nil, + derivedColumns: [String]? = nil, + isIdentifierQuoted: Bool = false + ) { + self.tableName = tableName + self.alias = alias + self.schema = schema + self.derivedColumns = derivedColumns + self.isIdentifierQuoted = isIdentifierQuoted + } + + public var identifier: String { + alias ?? tableName + } + + public var isDerived: Bool { + derivedColumns != nil + } +} + +// The quoted name the cursor is in, as offsets like `prefixRange`: its opening quote, and its closing quote when the +// name has one of its own. +public struct SQLQuotedName: Sendable, Equatable { + public let open: Int + public let close: Int? + + public init(open: Int, close: Int?) { + self.open = open + self.close = close + } +} + +public struct SQLContext: Sendable { + public let clauseType: SQLClauseType + public let prefix: String + public let prefixRange: Range // UTF-16 offsets into the analyzed text + public let dotPrefix: String? // "u" in "u.name" + public let tableReferences: [TableReference] + public let isInsideString: Bool + public let isInsideComment: Bool + + public let cteNames: [String] + public let nestingLevel: Int + public let currentFunction: String? + public let isAfterComma: Bool + public let expectsObjectName: Bool // The table-operand slot of FROM, JOIN or INTO + public let comparisonColumn: String? // The column on the other side of a comparison the cursor is a value in + public let quotedName: SQLQuotedName? + + public init( + clauseType: SQLClauseType, + prefix: String, + prefixRange: Range, + dotPrefix: String?, + tableReferences: [TableReference], + isInsideString: Bool, + isInsideComment: Bool, + cteNames: [String] = [], + nestingLevel: Int = 0, + currentFunction: String? = nil, + isAfterComma: Bool = false, + expectsObjectName: Bool = false, + comparisonColumn: String? = nil, + quotedName: SQLQuotedName? = nil + ) { + self.clauseType = clauseType + self.prefix = prefix + self.prefixRange = prefixRange + self.dotPrefix = dotPrefix + self.tableReferences = tableReferences + self.isInsideString = isInsideString + self.isInsideComment = isInsideComment + self.cteNames = cteNames + self.nestingLevel = nestingLevel + self.currentFunction = currentFunction + self.isAfterComma = isAfterComma + self.expectsObjectName = expectsObjectName + self.comparisonColumn = comparisonColumn + self.quotedName = quotedName + } + + public func replacingTableReferences(_ references: [TableReference]) -> SQLContext { + SQLContext( + clauseType: clauseType, + prefix: prefix, + prefixRange: prefixRange, + dotPrefix: dotPrefix, + tableReferences: references, + isInsideString: isInsideString, + isInsideComment: isInsideComment, + cteNames: cteNames, + nestingLevel: nestingLevel, + currentFunction: currentFunction, + isAfterComma: isAfterComma, + expectsObjectName: expectsObjectName, + comparisonColumn: comparisonColumn, + quotedName: quotedName + ) + } + + // Ranks the statement-start keywords a popup seeds itself with before the first analysis answers. + public static let unanalyzed = SQLContext( + clauseType: .unknown, + prefix: "", + prefixRange: 0..<0, + dotPrefix: nil, + tableReferences: [], + isInsideString: false, + isInsideComment: false + ) +} + +public final class SQLContextAnalyzer { + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let openParen = UInt16(UnicodeScalar("(").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) + private static let dot = UInt16(UnicodeScalar(".").value) + private static let comma = UInt16(UnicodeScalar(",").value) + private static let space = UInt16(UnicodeScalar(" ").value) + private static let tab = UInt16(UnicodeScalar("\t").value) + private static let cr = UInt16(UnicodeScalar("\r").value) + private static let colon = UInt16(UnicodeScalar(":").value) + private static let underscore = UInt16(UnicodeScalar("_").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let dollar = UInt16(UnicodeScalar("$").value) + private static let hash = UInt16(UnicodeScalar("#").value) + + // The linear DML clauses are left to `scanLinearClause`: a fixed-priority sweep cannot tell the clause nearest the + // cursor from an earlier one. Order matters here, the more specific pattern first. + private static let structuralClauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { + let patterns: [(String, SQLClauseType)] = [ + ("\\bADD\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+.*?\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", + .alterTableColumn), + ("\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", .alterTableColumn), + ("\\bFIRST\\s*$", .alterTable), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+ADD\\s+CONSTRAINT\\s+\\w*$", .alterTable), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+ADD\\s+\\w*$", .alterTable), + ( + "\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+(?:\\([^)]*\\))?" + + "(?:\\s+(?:NOT\\s+)?NULL|\\s+DEFAULT(?:\\s+[^\\s]+)?|\\s+AUTO_INCREMENT" + + "|\\s+UNSIGNED|\\s+COMMENT(?:\\s+'[^']*')?)*\\s*$", + .columnDef + ), + ("\\b(?:ADD|MODIFY|CHANGE)\\s+COLUMN\\s+\\w+\\s*$", .columnDef), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)" + + "\\s+(?:COLUMN\\s+)?[`\"']?\\w*[`\"']?\\s*$", .alterTableColumn), + ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), + ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), + ("\\bCREATE\\s+(?:TEMPORARY\\s+)?TABLE\\s+[^;]*\\([^)]*\\)\\s*\\w*$", .createTable), + ("\\bCREATE\\s+(?:TEMPORARY\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?\\w*$", .createTable), + ("\\bDROP\\s+(?:TABLE|VIEW|INDEX)\\s+(?:IF\\s+EXISTS\\s+)?\\w*$", .dropObject), + ("\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+\\w+\\s*\\([^)]*$", .createIndex), + ("\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w*$", .createIndex), + ("\\bCREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:MATERIALIZED\\s+)?VIEW\\s+\\w+\\s+AS\\s+[^;]*$", + .createView), + ("\\bCREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:MATERIALIZED\\s+)?VIEW\\s+\\w*$", .createView), + ("\\bUSING\\s*\\([^)]*$", .using), + ("\\bOVER\\s*\\([^)]*$", .window), + ("\\bPARTITION\\s+BY\\s+[^)]*$", .window), + ("\\bIN\\s*\\([^)]*$", .inList), + ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), + ("\\bVALUES\\s*(?:\\([^)]*\\)\\s*,?\\s*)+\\w*$", .values), + ("\\bVALUES\\s*\\([^)]*$", .values), + ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), + ] + return patterns.map { pattern, clause in + (compileRegex(pattern, options: .caseInsensitive), clause) + } + }() + + private static let cteFirstRegex = compileRegex( + "(?i)\\bWITH\\s+(?:RECURSIVE\\s+)?([\\w]+)\\s+AS\\s*\\(" + ) + + private static let cteCommaRegex = compileRegex("(?i),\\s*([\\w]+)\\s+AS\\s*\\(") + + // Read on the code view, where a quoted name's content is masked to word characters, so a segment may be quoted + // and hold any character. + private static let quotedSegment = "[`\"'\\[]?\\w+[`\"'\\]]?" + + private static let tableRefRegexes: [NSRegularExpression] = { + let path = "(\(quotedSegment)(?:\\.\(quotedSegment))*)" + let alias = "(?:\\s+(?:AS\\s+)?(\(quotedSegment)))?" + let patterns = [ + "(?i)(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\\s*(?:OUTER)?\\s*JOIN\\s+\(path)\(alias)", + "(?i)\\bUPDATE\\s+\(path)\(alias)", + "(?i)\\bINSERT\\s+INTO\\s+\(path)", + "(?i)\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+\(path)" + ] + return patterns.map { compileRegex($0) } + }() + + // Every comma-separated entry up to the next clause keyword, so `FROM a, b, c` puts each table in scope. + private static let fromListRegex = compileRegex( + "(?i)\\bFROM\\s+([\\s\\S]+?)" + + "(?=\\b(?:WHERE|GROUP|ORDER|HAVING|LIMIT|OFFSET|JOIN|INNER|LEFT|RIGHT" + + "|FULL|CROSS|NATURAL|ON|USING|UNION|INTERSECT|EXCEPT|RETURNING|SET" + + "|WINDOW|FETCH|FOR)\\b|[;()]|$)" + ) + + private static let alterTableRegex = compileRegex("(?i)\\bALTER\\s+TABLE\\s+(\(quotedSegment))") + + private static let derivedTableParser = DerivedTableParser() + + private static func isWhitespace(_ ch: UInt16) -> Bool { + ch == space || ch == tab || ch == newline || ch == cr + } + + public init() {} + + // MARK: - Main Analysis + + public func analyze(query: String, cursorPosition: Int, grammar: SQLLexicalGrammar) -> SQLContext { + let nsQuery = query as NSString + let safePosition = min(cursorPosition, nsQuery.length) + + let located = SQLStatementScanner.locatedStatementAtCursor( + in: query, cursorPosition: safePosition, grammar: grammar + ) + let statementOffset = located.offset + let nsStatement = located.sql as NSString + let cursor = max(0, min(safePosition - statementOffset, nsStatement.length)) + let text = Self.lex(nsStatement, cursor: cursor, grammar: grammar) + + switch text.cursor { + case .literal, .comment: + return SQLContext( + clauseType: .unknown, + prefix: "", + prefixRange: safePosition..(tableReferences) + + if let alterTableName = extractAlterTableName(from: text.forReferences) { + let alterRef = TableReference(tableName: alterTableName, alias: nil) + if seenReferences.insert(alterRef).inserted { + tableReferences.append(alterRef) + } + } + + let nestingLevel = calculateNestingLevel(in: text.code, end: cursor) + + let currentFunction = detectFunctionContext(in: text, end: cursor) + + let isAfterComma = checkIfAfterComma(text.code, end: cursor) + + // The token being typed is what completion filters on, so it never counts as a committed clause keyword, and + // a cursor inside a quoted name reads its clause from before the opening quote. + var clauseEnd = prefixStart + var quotedName: SQLQuotedName? + if case .quotedIdentifier(let start, let close) = text.cursor { + clauseEnd = min(clauseEnd, start) + quotedName = SQLQuotedName(open: statementOffset + start, close: close.map { statementOffset + $0 }) + } + let textBeforePrefix = text.code.substring(to: clauseEnd) + + let clauseText = nestingLevel > 0 + ? extractInnermostSubqueryText(from: textBeforePrefix) + : textBeforePrefix + + let resolution = determineClauseType( + textBeforeCursor: clauseText, + dotPrefix: dotPrefix, + currentFunction: currentFunction + ) + + let isCastTarget = endsWithCastOperator(text.code, before: clauseEnd) + let comparisonColumn = comparisonTarget(in: text, before: clauseEnd) + + return SQLContext( + clauseType: isCastTarget ? .castTarget : resolution.clause, + prefix: prefix, + prefixRange: (statementOffset + prefixStart).. LexedStatement { + let length = statement.length + var units = [UInt16](repeating: 0, count: length) + statement.getCharacters(&units, range: NSRange(location: 0, length: length)) + + let masksDollarAndHash = grammar.contains(.dollarAndHashInIdentifiers) + var state = CursorState.code + var quotedIdentifierStarts: [Int: Int] = [:] + var doubleQuotedLiterals: [Range] = [] + var cursorName: SQLNonCodeSpan.Span? + var openQuoteAtEnd: UInt16? + var index = 0 + + while index < length { + guard let span = SQLNonCodeSpan.span(at: index, in: statement, grammar: grammar), + span.kind != .executableComment else { + if masksDollarAndHash, index > 0, units[index] == dollar || units[index] == hash, + SQLNonCodeSpan.isWordUnit(units[index - 1]) { + units[index] = underscore + } + index += 1 + continue + } + + let isIdentifier = isQuotedIdentifier(span, in: statement, grammar: grammar) + if holds(span, cursor: cursor) { + if span.kind.isComment { + state = .comment + } else if isIdentifier { + state = .quotedIdentifier(start: span.start, close: nil) + cursorName = span + } else { + state = .literal + } + } + if !span.isTerminated, span.kind == .quoted { + openQuoteAtEnd = statement.character(at: span.start) + } + + if isIdentifier { + for offset in (span.start + 1).. Bool { + let opener = statement.character(at: name.start) + guard openQuoteAtEnd != opener else { return false } + guard opener == openBracket else { return true } + return !((name.start + 1)..<(name.end - 1)).contains { statement.character(at: $0) == openBracket } + } + + private static func makeString(_ units: [UInt16]) -> NSString { + units.withUnsafeBufferPointer { buffer in + buffer.baseAddress.map { NSString(characters: $0, length: units.count) } ?? NSString() + } + } + + private static func blank(_ units: inout [UInt16], _ range: Range) { + for offset in range where units[offset] != newline { + units[offset] = space + } + } + + private static func holds(_ span: SQLNonCodeSpan.Span, cursor: Int) -> Bool { + guard cursor > span.start else { return false } + if !span.isTerminated || span.kind == .lineComment { + return cursor <= span.end + } + return cursor < span.end + } + + private static func isQuotedIdentifier( + _ span: SQLNonCodeSpan.Span, + in text: NSString, + grammar: SQLLexicalGrammar + ) -> Bool { + guard span.kind == .quoted else { return false } + switch text.character(at: span.start) { + case backtick, openBracket: + return true + case doubleQuote: + // Stands in for "a double quote opens a string": ClickHouse and DynamoDB set it too, with `"` an identifier. + return !grammar.contains(.backslashEscapesInDoubleQuotes) + default: + return false + } + } + + // MARK: - CTE Support + + private func extractCTENames(from text: LexedStatement) -> [String] { + var cteNames: [String] = [] + let nsRange = NSRange(location: 0, length: text.code.length) + + if let match = Self.cteFirstRegex.firstMatch(in: text.codeString, range: nsRange) { + let nameNSRange = match.range(at: 1) + if nameNSRange.location != NSNotFound { + cteNames.append(text.original.substring(with: nameNSRange)) + } + } + + Self.cteCommaRegex.enumerateMatches(in: text.codeString, range: nsRange) { match, _, _ in + if let match = match { + let nameNSRange = match.range(at: 1) + if nameNSRange.location != NSNotFound { + cteNames.append(text.original.substring(with: nameNSRange)) + } + } + } + + return cteNames + } + + // MARK: - Subquery Support + + private func calculateNestingLevel(in code: NSString, end: Int) -> Int { + var level = 0 + for index in 0.. String { + let ns = textBeforeCursor as NSString + let length = ns.length + var parenStack: [Int] = [] + + for i in 0.. = [ + "SELECT", "FROM", "WHERE", "IN", "EXISTS", "NOT" + ] + + private func detectFunctionContext(in text: LexedStatement, end: Int) -> String? { + var parenStack: [String?] = [] + var wordStart = -1 + var lastWord: String? + + for i in 0..= 0 { + lastWord = text.original.substring(with: NSRange(location: wordStart, length: i - wordStart)) + wordStart = -1 + } + if ch == Self.openParen { + parenStack.append(lastWord) + lastWord = nil + } else if ch == Self.closeParen, !parenStack.isEmpty { + parenStack.removeLast() + } + } + + guard let precedingWord = parenStack.last, let funcName = precedingWord, + !Self.subqueryKeywords.contains(funcName.uppercased()) else { return nil } + return funcName + } + + // MARK: - Comma Detection + + private func checkIfAfterComma(_ code: NSString, end: Int) -> Bool { + var i = end - 1 + while i >= 0 { + let ch = code.character(at: i) + if Self.isWhitespace(ch) { + i -= 1 + continue + } + return ch == Self.comma + } + return false + } + + // MARK: - Comparison + + private func comparisonTarget(in text: LexedStatement, before prefixStart: Int) -> String? { + let code = text.code + var index = skippingWhitespaceBackwards(in: code, from: min(prefixStart, code.length)) + guard let operatorStart = comparisonOperatorStart(in: code, endingAt: index) else { return nil } + + index = skippingWhitespaceBackwards(in: code, from: operatorStart) + let identifierEnd = index + while index > 0, SQLTokenBoundary.isIdentifierChar(code.character(at: index - 1)) { + index -= 1 + } + guard index < identifierEnd else { return nil } + + let identifier = text.original.substring(with: NSRange(location: index, length: identifierEnd - index)) + guard !Self.comparisonStopWords.contains(identifier.uppercased()) else { return nil } + return identifier + } + + private func skippingWhitespaceBackwards(in text: NSString, from start: Int) -> Int { + var index = start + while index > 0, Self.isWhitespace(text.character(at: index - 1)) { + index -= 1 + } + return index + } + + private func comparisonOperatorStart(in text: NSString, endingAt end: Int) -> Int? { + for symbol in Self.comparisonOperators { + let length = (symbol as NSString).length + guard end >= length else { continue } + let range = NSRange(location: end - length, length: length) + guard text.substring(with: range).caseInsensitiveCompare(symbol) == .orderedSame else { continue } + + let start = end - length + let isWordOperator = symbol.first?.isLetter == true + if isWordOperator, start > 0, SQLTokenBoundary.isIdentifierChar(text.character(at: start - 1)) { + continue + } + return start + } + return nil + } + + private static let comparisonOperators: [String] = ["<>", "!=", "<=", ">=", "=", "<", ">", "IN", "LIKE", "ILIKE"] + + private static let comparisonStopWords: Set = [ + "AND", "OR", "NOT", "WHERE", "ON", "HAVING", "SELECT", "SET", "WHEN", "THEN", "ELSE", "BY" + ] + + private func endsWithCastOperator(_ text: NSString, before prefixStart: Int) -> Bool { + guard prefixStart >= 2, prefixStart <= text.length else { return false } + return text.character(at: prefixStart - 1) == Self.colon && text.character(at: prefixStart - 2) == Self.colon + } + + // MARK: - Prefix + + private func extractPrefix( + in text: LexedStatement, + cursor: Int, + grammar: SQLLexicalGrammar + ) -> (prefix: String, start: Int, dotPrefix: String?) { + let ns = text.original + let segment = SQLTokenBoundary.segment(in: ns, endingAt: cursor, grammar: grammar) + let start = segment.start + let prefix = SQLTokenBoundary.matchText(of: ns.substring(with: NSRange(location: start, length: cursor - start))) + + let tokenStart = segment.openQuote ?? start + guard tokenStart > 0, ns.character(at: tokenStart - 1) == Self.dot else { + return (prefix, start, nil) + } + let qualifierEnd = tokenStart - 1 + if case .quotedIdentifier(let spanStart, _) = text.cursor, qualifierEnd > spanStart { + return (prefix, start, nil) + } + + if let quotedStart = text.quotedIdentifierStarts[qualifierEnd] { + let raw = ns.substring(with: NSRange(location: quotedStart, length: qualifierEnd - quotedStart)) + return (prefix, start, Self.unquoted(raw)) + } + + let qualifierStart = SQLTokenBoundary.segmentStart(in: ns, endingAt: qualifierEnd, grammar: grammar) + guard qualifierStart < qualifierEnd else { + return (prefix, start, nil) + } + + let qualifierRange = NSRange(location: qualifierStart, length: qualifierEnd - qualifierStart) + let dotPrefix = SQLTokenBoundary.matchText(of: ns.substring(with: qualifierRange)) + return (prefix, start, dotPrefix) + } + + // MARK: - Table References + + private static let tableRefKeywords: Set = [ + "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "NATURAL", + "JOIN", "ON", "AND", "OR", "WHERE", "SELECT", "FROM", "AS", "SET" + ] + + private static let bareIdentifierRegex = compileRegex("^\\w+$") + + private static func isBareIdentifier(_ value: String) -> Bool { + let range = NSRange(location: 0, length: (value as NSString).length) + return bareIdentifierRegex.firstMatch(in: value, range: range) != nil + } + + private static let identifierQuoteChars = CharacterSet(charactersIn: "`\"'[]") + + private static func closingQuote(for character: Character) -> Character? { + switch character { + case "\"", "`", "'": + return character + case "[": + return "]" + default: + return nil + } + } + + private static func isQuoted(_ raw: String) -> Bool { + raw.first.flatMap(closingQuote(for:)) != nil + } + + private static func unquoted(_ raw: String) -> String { + guard let first = raw.first, let close = closingQuote(for: first) else { + return raw.trimmingCharacters(in: identifierQuoteChars) + } + var body = raw.dropFirst() + if body.last == close { + body = body.dropLast() + } + let closer = String(close) + return String(body).replacingOccurrences(of: closer + closer, with: closer) + } + + // A quoted alias is a name whatever it spells; a bare keyword is the next clause. + private static func aliasName(_ raw: String) -> String? { + let name = unquoted(raw) + guard !name.isEmpty else { return nil } + guard isQuoted(raw) || !tableRefKeywords.contains(name.uppercased()) else { return nil } + return name + } + + private struct PathSegment { + let masked: String + let name: String + let isQuoted: Bool + } + + private static func pathSegments(_ range: NSRange, in text: LexedStatement) -> [PathSegment] { + var segments: [PathSegment] = [] + var start = range.location + let end = range.location + range.length + + func append(upTo stop: Int) { + guard stop > start else { return } + let piece = NSRange(location: start, length: stop - start) + let raw = text.original.substring(with: piece) + segments.append(PathSegment(masked: text.code.substring(with: piece), name: unquoted(raw), isQuoted: isQuoted(raw))) + } + + for index in range.location.. Bool) -> [NSRange] { + var pieces: [NSRange] = [] + var start = range.location + let end = range.location + range.length + for index in range.location.. start { + pieces.append(NSRange(location: start, length: index - start)) + } + start = index + 1 + } + if end > start { + pieces.append(NSRange(location: start, length: end - start)) + } + return pieces + } + + private func extractTableReferences(from text: LexedStatement) -> [TableReference] { + var references: [TableReference] = [] + var seen = Set() + + appendFromListReferences(from: text, into: &references, seen: &seen) + + let nsRange = NSRange(location: 0, length: text.code.length) + + for regex in Self.tableRefRegexes { + regex.enumerateMatches(in: text.codeString, range: nsRange) { match, _, _ in + guard let match = match else { return } + + let pathRange = match.range(at: 1) + guard pathRange.location != NSNotFound else { return } + + let segments = Self.pathSegments(pathRange, in: text) + guard let table = segments.last, !table.name.isEmpty else { return } + guard table.isQuoted || !Self.tableRefKeywords.contains(table.name.uppercased()) else { return } + + let schema = segments.count >= 2 ? segments[segments.count - 2].name : nil + + var alias: String? + var isAliasQuoted = false + if match.numberOfRanges > 2 { + let aliasNSRange = match.range(at: 2) + if aliasNSRange.location != NSNotFound { + let raw = text.original.substring(with: aliasNSRange) + alias = Self.aliasName(raw) + isAliasQuoted = Self.isQuoted(raw) + } + } + + let ref = TableReference( + tableName: table.name, + alias: alias, + schema: schema, + isIdentifierQuoted: alias == nil ? table.isQuoted : isAliasQuoted + ) + if seen.insert(ref).inserted { + references.append(ref) + } + } + } + + return references + } + + // Columns attach to a matching reference, such as a CTE aliased in FROM, and the derived table or CTE is still + // registered under its own name so it resolves by either identifier. + private func mergeDerivedTables( + _ derivedTables: [DerivedTable], + cteNames: [String], + into references: inout [TableReference] + ) { + let derivedColumnsByAlias = Dictionary( + derivedTables.map { ($0.alias.lowercased(), $0.columns) }, + uniquingKeysWith: { first, _ in first } + ) + + for index in references.indices { + let ref = references[index] + for key in [ref.tableName.lowercased(), ref.identifier.lowercased()] { + guard let columns = derivedColumnsByAlias[key] else { continue } + references[index] = TableReference( + tableName: ref.tableName, + alias: ref.alias, + schema: ref.schema, + derivedColumns: columns, + isIdentifierQuoted: ref.isIdentifierQuoted + ) + break + } + } + + var present = Set(references.map { $0.identifier.lowercased() }) + for derived in derivedTables where present.insert(derived.alias.lowercased()).inserted { + references.append( + TableReference(tableName: derived.alias, alias: derived.alias, derivedColumns: derived.columns) + ) + } + for cteName in cteNames where present.insert(cteName.lowercased()).inserted { + references.append(TableReference(tableName: cteName, alias: nil)) + } + } + + private func appendFromListReferences( + from text: LexedStatement, + into references: inout [TableReference], + seen: inout Set + ) { + let nsRange = NSRange(location: 0, length: text.code.length) + Self.fromListRegex.enumerateMatches(in: text.codeString, range: nsRange) { match, _, _ in + guard let match = match else { return } + let listRange = match.range(at: 1) + guard listRange.location != NSNotFound else { return } + + for entry in Self.ranges(in: listRange, of: text.code, splitBy: { $0 == Self.comma }) { + guard let ref = self.tableReference(fromListEntry: entry, in: text) else { continue } + if seen.insert(ref).inserted { + references.append(ref) + } + } + } + } + + private func tableReference(fromListEntry entry: NSRange, in text: LexedStatement) -> TableReference? { + let tokens = Self.ranges(in: entry, of: text.code, splitBy: Self.isWhitespace) + guard let pathToken = tokens.first else { return nil } + + let segments = Self.pathSegments(pathToken, in: text) + guard let table = segments.last, !table.name.isEmpty else { return nil } + // Only a plain identifier path, so `FROM (SELECT ...) x` never yields a phantom table. + guard segments.allSatisfy({ + Self.isBareIdentifier($0.masked.trimmingCharacters(in: Self.identifierQuoteChars)) + }) else { return nil } + guard table.isQuoted || !Self.tableRefKeywords.contains(table.name.uppercased()) else { return nil } + + let schema = segments.count >= 2 ? segments[segments.count - 2].name : nil + + var alias: String? + var isAliasQuoted = false + if tokens.count >= 2 { + var candidate = tokens[1] + if text.original.substring(with: candidate).uppercased() == "AS", tokens.count >= 3 { + candidate = tokens[2] + } + let raw = text.original.substring(with: candidate) + alias = Self.aliasName(raw) + isAliasQuoted = Self.isQuoted(raw) + } + + return TableReference( + tableName: table.name, + alias: alias, + schema: schema, + isIdentifierQuoted: alias == nil ? table.isQuoted : isAliasQuoted + ) + } + + private func extractAlterTableName(from text: LexedStatement) -> String? { + 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)) + } + + // MARK: - Clause Detection + + private struct ClauseResolution { + let clause: SQLClauseType + let expectsObjectName: Bool + } + + private func determineClauseType( + textBeforeCursor: String, + dotPrefix: String?, + currentFunction: String? = nil + ) -> ClauseResolution { + if dotPrefix != nil { + return ClauseResolution(clause: .select, expectsObjectName: false) + } + + // Bounds the regex work on a long statement. SQLContextAnalyzerWindowingTests pins the size. + let windowSize = 5_000 + let nsText = textBeforeCursor as NSString + let windowedText = nsText.length > windowSize + ? nsText.substring(from: nsText.length - windowSize) + : textBeforeCursor + + // Structural contexts first: `CREATE TABLE test (id ` looks like a call to `test(` but is a column definition. + let range = NSRange(location: 0, length: (windowedText as NSString).length) + for (regex, clause) in Self.structuralClauseRegexes { + if regex.firstMatch(in: windowedText, range: range) != nil { + return ClauseResolution(clause: clause, expectsObjectName: false) + } + } + + // The keyword nearest the cursor wins, which is what lets `... ON a = b JOIN |` read JOIN. + if let resolution = scanLinearClause(in: windowedText) { + return resolution + } + + if currentFunction != nil { + return ClauseResolution(clause: .functionArg, expectsObjectName: false) + } + + return ClauseResolution(clause: .unknown, expectsObjectName: false) + } + + // MARK: - Linear Clause Scan + + private enum ScanTokenKind { + case word + case openParen + case closeParen + case comma + case other + } + + private struct ScanToken { + let kind: ScanTokenKind + let upper: String + } + + // Walks backwards, skipping closed parentheses and balanced `CASE ... END`, and passes through an open + // parenthesis so the clause around a call or grouping is still found. + private func scanLinearClause(in cleaned: String) -> ClauseResolution? { + let tokens = tokenize(cleaned) + var depth = 0 + var pendingEnd = 0 + var sawObject = false + var slotClosed = false + + for index in stride(from: tokens.count - 1, through: 0, by: -1) { + let token = tokens[index] + switch token.kind { + case .closeParen: + depth += 1 + case .openParen: + if depth > 0 { depth -= 1 } + case .other: + continue + case .comma: + // A comma closes the operand slot, so a later table in `FROM a, b` still expects a name. + if depth == 0 { slotClosed = true } + case .word: + guard depth == 0 else { continue } + let word = token.upper + if word == "END" { + pendingEnd += 1 + slotClosed = true + continue + } + if word == "CASE" { + if pendingEnd > 0 { + pendingEnd -= 1 + slotClosed = true + continue + } + return ClauseResolution(clause: .caseExpression, expectsObjectName: false) + } + guard pendingEnd == 0 else { continue } + let previous = index > 0 ? tokens[index - 1].upper : nil + if let clause = clause(forKeyword: word, previous: previous, sawObject: sawObject) { + return clause + } + if !slotClosed { sawObject = true } + } + } + + return nil + } + + // `sawObject` means an operand already sits between the keyword and the cursor, so the slot is past. + private func clause(forKeyword word: String, previous: String?, sawObject: Bool) -> ClauseResolution? { + switch word { + case "WHEN", "THEN", "ELSE": + return ClauseResolution(clause: .caseExpression, expectsObjectName: false) + case "SELECT": + return ClauseResolution(clause: .select, expectsObjectName: false) + case "FROM": + return ClauseResolution(clause: .from, expectsObjectName: !sawObject) + case "JOIN": + return ClauseResolution(clause: .join, expectsObjectName: !sawObject) + case "INTO": + return ClauseResolution(clause: .into, expectsObjectName: !sawObject) + case "ON": + return ClauseResolution(clause: .on, expectsObjectName: false) + case "WHERE": + return ClauseResolution(clause: .where_, expectsObjectName: false) + case "AND", "OR": + return ClauseResolution(clause: .and, expectsObjectName: false) + case "HAVING": + return ClauseResolution(clause: .having, expectsObjectName: false) + case "SET": + return ClauseResolution(clause: .set, expectsObjectName: false) + case "RETURNING": + return ClauseResolution(clause: .returning, expectsObjectName: false) + case "UNION", "INTERSECT", "EXCEPT": + return ClauseResolution(clause: .union, expectsObjectName: false) + case "BY": + switch previous { + case "GROUP": + return ClauseResolution(clause: .groupBy, expectsObjectName: false) + case "ORDER": + return ClauseResolution(clause: .orderBy, expectsObjectName: false) + case "PARTITION": + return ClauseResolution(clause: .window, expectsObjectName: false) + default: + return nil + } + default: + return nil + } + } + + private func tokenize(_ text: String) -> [ScanToken] { + let ns = text as NSString + let length = ns.length + var tokens: [ScanToken] = [] + var wordStart = -1 + + func flushWord(end: Int) { + guard wordStart >= 0 else { return } + let word = ns.substring(with: NSRange(location: wordStart, length: end - wordStart)) + tokens.append(ScanToken(kind: .word, upper: word.uppercased())) + wordStart = -1 + } + + for i in 0.. = Set(keywords.filter { !$0.contains(" ") }.map { $0.lowercased() }) - /// Primary SQL keywords + public static func isKeyword(_ word: String) -> Bool { + keywordSet.contains(word.lowercased()) + } + static let keywords: [String] = [ // DQL "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "AS", @@ -90,9 +91,6 @@ public enum SQLKeywords { "EXPLAIN", "ANALYZE", "DESCRIBE", "SHOW" ] - // MARK: - Functions - - /// Aggregate functions static let aggregateFunctions: [(name: String, signature: String, doc: String)] = [ ("COUNT", "COUNT(expr)", "Count rows or non-null values"), ("SUM", "SUM(expr)", "Sum of values"), @@ -110,7 +108,6 @@ public enum SQLKeywords { ("JSON_ARRAYAGG", "JSON_ARRAYAGG(expr)", "Aggregate into JSON array"), ] - /// Date/Time functions static let dateTimeFunctions: [(name: String, signature: String, doc: String)] = [ ("NOW", "NOW()", "Current date and time"), ("CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP", "Current timestamp"), @@ -152,7 +149,6 @@ public enum SQLKeywords { ("MAKETIME", "MAKETIME(hour, minute, second)", "Create time value"), ] - /// String functions static let stringFunctions: [(name: String, signature: String, doc: String)] = [ ("CONCAT", "CONCAT(str1, str2, ...)", "Concatenate strings"), ("CONCAT_WS", "CONCAT_WS(sep, str1, ...)", "Concatenate with separator"), @@ -189,7 +185,6 @@ public enum SQLKeywords { ("TRANSLATE", "TRANSLATE(str, from, to)", "Replace characters"), ] - /// Numeric functions static let numericFunctions: [(name: String, signature: String, doc: String)] = [ ("ABS", "ABS(n)", "Absolute value"), ("ROUND", "ROUND(n, decimals)", "Round to decimals"), @@ -220,7 +215,6 @@ public enum SQLKeywords { ("PI", "PI()", "Pi constant"), ] - /// Null handling functions static let nullFunctions: [(name: String, signature: String, doc: String)] = [ ("COALESCE", "COALESCE(v1, v2, ...)", "First non-null value"), ("IFNULL", "IFNULL(expr, alt)", "Return alt if expr is null"), @@ -229,14 +223,12 @@ public enum SQLKeywords { ("ISNULL", "ISNULL(expr)", "Check if null"), ] - /// Type conversion functions static let conversionFunctions: [(name: String, signature: String, doc: String)] = [ ("CAST", "CAST(expr AS type)", "Convert to type"), ("CONVERT", "CONVERT(expr, type)", "Convert to type"), ("BINARY", "BINARY(str)", "Convert to binary string"), ] - /// Window functions static let windowFunctions: [(name: String, signature: String, doc: String)] = [ ("ROW_NUMBER", "ROW_NUMBER() OVER(...)", "Sequential row number"), ("RANK", "RANK() OVER(...)", "Rank with gaps"), @@ -251,7 +243,6 @@ public enum SQLKeywords { ("CUME_DIST", "CUME_DIST() OVER(...)", "Cumulative distribution"), ] - /// JSON functions (MySQL/PostgreSQL) static let jsonFunctions: [(name: String, signature: String, doc: String)] = [ ("JSON_EXTRACT", "JSON_EXTRACT(json, path)", "Extract value from JSON"), ("JSON_OBJECT", "JSON_OBJECT(key, value, ...)", "Create JSON object"), @@ -275,16 +266,12 @@ public enum SQLKeywords { ("JSONB_AGG", "JSONB_AGG(expr)", "Aggregate to JSONB array (PostgreSQL)"), ] - /// All functions combined static var allFunctions: [(name: String, signature: String, doc: String)] { aggregateFunctions + dateTimeFunctions + stringFunctions + numericFunctions + nullFunctions + conversionFunctions + windowFunctions + jsonFunctions } - // MARK: - Operators - - /// Comparison operators static let operators: [(symbol: String, doc: String)] = [ ("=", "Equal to"), ("<>", "Not equal to"), @@ -296,9 +283,6 @@ public enum SQLKeywords { ("<=>", "Null-safe equal (MySQL)"), ] - // MARK: - Completion Items - - /// Get all keyword completion items static func keywordItems() -> [SQLCompletionItem] { keywords.map { SQLCompletionItem.keyword($0) } } diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLSnippetMarker.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLSnippetMarker.swift similarity index 100% rename from apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLSnippetMarker.swift rename to apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLSnippetMarker.swift diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift new file mode 100644 index 0000000000..3c28e8f800 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift @@ -0,0 +1,131 @@ +import Foundation +import TableProSQLGrammar + +// The context analyzer and the completion adapter must agree on where the token under the cursor starts. +public enum SQLTokenBoundary { + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let closeBracket = UInt16(UnicodeScalar("]").value) + private static let underscore = UInt16(UnicodeScalar("_").value) + + // Letters beyond ASCII continue an identifier on every engine. `$` stays out: it opens a MongoDB pipeline stage, + // whose analyzer needs the token to start there. + private static let nonASCIIIdentifierScalars: CharacterSet = { + var set = CharacterSet.letters + set.formUnion(.decimalDigits) + set.formUnion(.nonBaseCharacters) + return set + }() + + static func isIdentifierChar(_ ch: UInt16) -> Bool { + if (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) { return true } + if ch >= 0x30 && ch <= 0x39 { return true } + return ch == underscore + } + + static func isTokenChar(_ ch: UInt16) -> Bool { + isIdentifierChar(ch) || ch == backtick || ch == doubleQuote + } + + private static let identifierQuotes = CharacterSet(charactersIn: "`\"[") + + // The segment keeps an opening quote so an accepted completion overwrites it, but no candidate's filter text + // carries one, and the matcher needs every pattern character in the target. + public static func matchText(of segment: String) -> String { + segment.trimmingCharacters(in: identifierQuotes) + } + + public static func segmentStart(in text: NSString, endingAt cursor: Int) -> Int { + walkBack(in: text, from: min(max(cursor, 0), text.length), brackets: false) + } + + public static func segmentStart(in text: NSString, endingAt cursor: Int, grammar: SQLLexicalGrammar) -> Int { + segment(in: text, endingAt: cursor, grammar: grammar).start + } + + // An editor that closes the pair leaves `[ord|]`. The quotes stay, so the segment starts after the opening one; + // while the quote is open, the segment covers it. + static func segment( + in text: NSString, + endingAt cursor: Int, + grammar: SQLLexicalGrammar + ) -> (start: Int, openQuote: Int?) { + let caret = min(max(cursor, 0), text.length) + let brackets = grammar.contains(.bracketQuotedIdentifiers) + let start = walkBack(in: text, from: caret, brackets: brackets) + guard start < caret, + let close = closingQuote(for: text.character(at: start), brackets: brackets), + !contains(close, in: text, from: start + 1, to: caret) else { + return (start, nil) + } + let runEnd = identifierRun(in: text, from: caret) + guard runEnd < text.length, text.character(at: runEnd) == close else { return (start, nil) } + return (start + 1, start) + } + + private static func walkBack(in text: NSString, from caret: Int, brackets: Bool) -> Int { + var start = caret + while start > 0 { + let unit = text.character(at: start - 1) + if unit < 0x80 { + if brackets, unit == openBracket { + start -= 1 + break + } + guard isTokenChar(unit) else { break } + start -= 1 + continue + } + let sequence = text.rangeOfComposedCharacterSequence(at: start - 1) + guard sequence.location + sequence.length <= start, + isNonASCIIIdentifierSequence(text.substring(with: sequence)) else { break } + start = sequence.location + } + return start + } + + private static func identifierRun(in text: NSString, from caret: Int) -> Int { + var end = caret + while end < text.length { + let unit = text.character(at: end) + if unit < 0x80 { + guard isIdentifierChar(unit) else { break } + end += 1 + continue + } + let sequence = text.rangeOfComposedCharacterSequence(at: end) + guard sequence.location == end, + isNonASCIIIdentifierSequence(text.substring(with: sequence)) else { break } + end += sequence.length + } + return end + } + + private static func closingQuote(for unit: UInt16, brackets: Bool) -> UInt16? { + switch unit { + case doubleQuote, backtick: + return unit + case openBracket where brackets: + return closeBracket + default: + return nil + } + } + + private static func contains(_ unit: UInt16, in text: NSString, from start: Int, to end: Int) -> Bool { + guard start < end else { return false } + return (start.. Bool { + guard !sequence.isEmpty else { return false } + return sequence.unicodeScalars.allSatisfy { nonASCIIIdentifierScalars.contains($0) } + } + + static func replacementRange(in text: NSString?, cursor: Int?, fallback: NSRange) -> NSRange { + guard let text, let cursor, cursor >= 0, cursor <= text.length else { return fallback } + let start = segmentStart(in: text, endingAt: cursor) + return NSRange(location: start, length: cursor - start) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/DerivedTableParserTests.swift diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/KeywordUppercaseHelperTests.swift diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLClauseDetectionTests.swift diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLCompletionInsertionTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionInsertionTests.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLCompletionInsertionTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionInsertionTests.swift diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift new file mode 100644 index 0000000000..d42b020d39 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionProviderCatalogTests.swift @@ -0,0 +1,86 @@ +import Foundation +@testable import TableProAutocomplete +import TableProSQLGrammar +import Testing + +private struct StubCatalog: SQLCompletionCatalog { + let tables: [String] + let columns: [String: [String]] + + func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? { + let name = aliasOrName.lowercased() + if let match = references.first(where: { $0.alias?.lowercased() == name }) { + return match.tableName + } + if let match = references.first(where: { $0.tableName.lowercased() == name }) { + return match.tableName + } + return tables.first { $0.lowercased() == name } + } + + func columnCompletionItems(for tableName: String, schema: String?) -> [SQLCompletionItem] { + (columns[tableName.lowercased()] ?? []).map { SQLCompletionItem.column($0, dataType: nil, tableName: tableName) } + } + + func tableCompletionItems() -> [SQLCompletionItem] { + tables.map { SQLCompletionItem.table($0) } + } + + func tableCompletionItems(inSchema schema: String) -> [SQLCompletionItem] { [] } + func schemaCompletionItems() -> [SQLCompletionItem] { [] } + func namespaceCompletionItems() -> [SQLCompletionItem] { [] } + func isKnownSchema(_ name: String) -> Bool { false } + func isKnownDatabase(_ name: String) -> Bool { false } + func allowedValues(forColumn column: String, in references: [TableReference]) -> [String] { [] } + + func allColumnsInScope(for references: [TableReference]) -> [SQLCompletionItem] { + references.flatMap { columnCompletionItems(for: $0.tableName, schema: $0.schema) } + } + + func allColumnsFromCachedTables() -> [SQLCompletionItem] { [] } +} + +struct SQLCompletionProviderCatalogTests { + private let provider = SQLCompletionProvider( + catalog: StubCatalog(tables: ["customers", "orders"], columns: ["orders": ["id", "total"]]), + grammar: .engine("SQLite"), + vocabulary: .generic + ) + + @Test("A table slot leads with the table the prefix names") + func tableSlotLeadsWithTheTable() async { + let session = await provider.completionSession(text: "SELECT * FROM or", cursorPosition: 16) + #expect(session.items.first?.label == "orders") + } + + @Test("An alias with a dot offers its table's columns") + func aliasDotOffersColumns() async { + let session = await provider.completionSession(text: "SELECT o. FROM orders o", cursorPosition: 9) + #expect(Set(session.items.map(\.label)) == ["id", "total"]) + } + + @Test("UPDATE offers tables") + func updateOffersTables() async { + let session = await provider.completionSession(text: "UPDATE or", cursorPosition: 9) + #expect(session.items.contains { $0.label == "orders" && $0.kind == .table }) + } + + @Test("A cursor in a string offers nothing") + func stringOffersNothing() async { + let text = "SELECT * FROM orders WHERE note = 'or" + let session = await provider.completionSession(text: text, cursorPosition: (text as NSString).length) + #expect(session.items.isEmpty) + #expect(session.context.isInsideString) + } + + @Test("A vocabulary's statement completions replace the generic statement keywords") + func vocabularyStatementCompletions() { + let custom = SQLCompletionProvider( + catalog: nil, + grammar: .ansi, + vocabulary: SQLCompletionVocabulary(statementCompletions: [.init(label: "FIND", insertText: "FIND ")]) + ) + #expect(custom.statementStartCompletionItems().map(\.label) == ["FIND"]) + #expect(provider.statementStartCompletionItems().contains { $0.label == "SELECT" }) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionWindowTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionWindowTests.swift new file mode 100644 index 0000000000..a09b2b874e --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLCompletionWindowTests.swift @@ -0,0 +1,35 @@ +import Foundation +@testable import TableProAutocomplete +import Testing + +struct SQLCompletionWindowTests { + @Test("A short text is analyzed whole") + func shortTextIsWhole() { + let window = SQLCompletionWindow.cut("SELECT * FROM or" as NSString, around: 16) + #expect(window.text == "SELECT * FROM or") + #expect(window.start == 0) + } + + @Test("A long text is cut to the radius around the caret") + func longTextIsCut() { + let text = String(repeating: "x", count: 12_000) as NSString + let window = SQLCompletionWindow.cut(text, around: 6_000) + #expect(window.start == 1_000) + #expect((window.text as NSString).length == 10_000) + } + + @Test( + "Nothing completes right after a statement break with nothing behind it", + arguments: [ + ("SELECT 1;", 9, true), + ("SELECT 1\n", 9, true), + ("SELECT 1;\n ", 9, true), + ("SELECT 1; SELECT", 9, false), + ("SELECT", 6, false), + ("", 0, false) + ] + ) + func statementBreak(text: String, offset: Int, suppressed: Bool) { + #expect(SQLCompletionTriggerPolicy.suppressesAfterStatementBreak(in: text as NSString, at: offset) == suppressed) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzer+ANSI.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzer+ANSI.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzer+ANSI.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzer+ANSI.swift diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerCaseInsensitiveTests.swift diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerLexicalTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerLexicalTests.swift new file mode 100644 index 0000000000..0290c29fe5 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerLexicalTests.swift @@ -0,0 +1,192 @@ +import Foundation +@testable import TableProAutocomplete +import TableProSQLGrammar +import Testing + +struct SQLContextAnalyzerLexicalTests { + private let analyzer = SQLContextAnalyzer() + + private func context(_ query: String, on engine: String, cursor: Int? = nil) -> SQLContext { + analyzer.analyze( + query: query, + cursorPosition: cursor ?? (query as NSString).length, + grammar: .engine(engine) + ) + } + + // An apostrophe in a comment once opened a string that ran to the cursor, so nothing after it completed. + @Test( + "A quote inside a comment opens nothing", + arguments: [ + ("-- last week's orders\nSELECT * FROM or", "SQLite"), + ("-- last week's orders\nSELECT * FROM or", "PostgreSQL"), + ("SELECT * /* it's */ FROM or", "PostgreSQL"), + ("SELECT * /* it's */ FROM or", "SQL Server"), + ("/* a /* b */ 'c */ SELECT * FROM or", "PostgreSQL") + ] + ) + func quoteInACommentOpensNothing(query: String, engine: String) { + let result = context(query, on: engine) + #expect(!result.isInsideString) + #expect(!result.isInsideComment) + #expect(result.clauseType == .from) + #expect(result.prefix == "or") + } + + @Test( + "An alias after a commented line still resolves", + arguments: [ + ("SELECT * FROM orders o -- don't\nWHERE o.", "SQLite"), + ("SELECT * FROM orders o # don't\nWHERE o.", "MySQL") + ] + ) + func aliasAfterACommentResolves(query: String, engine: String) { + let result = context(query, on: engine) + #expect(!result.isInsideString) + #expect(result.dotPrefix == "o") + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + } + + // A backslash is literal on these engines, so `'C:\'` is a closed string. + @Test( + "A trailing backslash closes the string where the engine says so", + arguments: ["SQLite", "PostgreSQL", "SQL Server", "Oracle", "DuckDB"] + ) + func literalBackslashClosesTheString(engine: String) { + let result = context("SELECT * FROM files f WHERE f.path = 'C:\\' AND f.", on: engine) + #expect(!result.isInsideString) + #expect(result.dotPrefix == "f") + #expect(result.tableReferences.contains { $0.tableName == "files" && $0.alias == "f" }) + } + + @Test("An escaped backslash closes the string on MySQL") + func escapedBackslashClosesTheStringOnMySQL() { + let result = context("SELECT * FROM files f WHERE f.path = 'C:\\\\' AND f.", on: "MySQL") + #expect(!result.isInsideString) + #expect(result.dotPrefix == "f") + } + + @Test("An escaped quote keeps the string open on MySQL") + func escapedQuoteKeepsTheStringOpenOnMySQL() { + let result = context("SELECT * FROM t WHERE x = 'it\\'s AND ", on: "MySQL") + #expect(result.isInsideString) + } + + @Test("A dollar-quoted body is one literal on PostgreSQL") + func dollarQuotedBodyIsOneLiteral() { + let query = "SELECT $$it's$$, o. FROM orders o" + let result = context(query, on: "PostgreSQL", cursor: 19) + #expect(!result.isInsideString) + #expect(result.dotPrefix == "o") + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + } + + @Test("A doubled quote stays inside the literal") + func doubledQuoteStaysInside() { + let result = context("SELECT * FROM t WHERE x = 'it''s' AND ", on: "SQLite") + #expect(!result.isInsideString) + #expect(result.clauseType == .and) + #expect(result.tableReferences.contains { $0.tableName == "t" }) + } + + @Test( + "An open string is silent on every engine", + arguments: ["SQLite", "PostgreSQL", "SQL Server", "Oracle", "DuckDB", "MySQL"] + ) + func openStringIsSilent(engine: String) { + let result = context("SELECT * FROM users WHERE name = 'jo", on: engine) + #expect(result.isInsideString) + #expect(result.prefix.isEmpty) + } + + @Test("A cursor inside a comment is silent", arguments: ["SQLite", "PostgreSQL", "MySQL"]) + func cursorInACommentIsSilent(engine: String) { + let result = context("SELECT * FROM orders -- or", on: engine) + #expect(result.isInsideComment) + } + + @Test("A comment in an earlier statement does not reach the next one") + func earlierStatementCommentStaysThere() { + let result = context("-- don't\nSELECT 1;\nSELECT * FROM or", on: "SQLite") + #expect(result.clauseType == .from) + #expect(result.prefix == "or") + } + + @Test( + "An open double quote is a name where the engine quotes identifiers with it", + arguments: ["PostgreSQL", "SQLite", "Oracle", "SQL Server", "DuckDB"] + ) + func openDoubleQuoteIsAnIdentifier(engine: String) { + let result = context("SELECT * FROM \"Inv", on: engine) + #expect(!result.isInsideString) + #expect(result.clauseType == .from) + #expect(result.expectsObjectName) + #expect(result.prefix == "Inv") + #expect(result.prefixRange == 14..<18) + } + + @Test("An open double quote is a string on MySQL") + func openDoubleQuoteIsAStringOnMySQL() { + #expect(context("SELECT * FROM \"Inv", on: "MySQL").isInsideString) + } + + // ClickHouse quotes identifiers with `"`, but its grammar carries the backslash flag the analyzer reads as "a + // double quote is a string". This pins today's silence there. + @Test("An open double quote stays silent on ClickHouse") + func openDoubleQuoteStaysSilentOnClickHouse() { + #expect(context("SELECT * FROM \"Inv", on: "ClickHouse").isInsideString) + } + + @Test("A string's parenthesis opens no subquery or call") + func parenthesisInAStringIsText() { + let result = context("SELECT * FROM orders o WHERE o.note = '(' AND o.", on: "PostgreSQL") + #expect(result.nestingLevel == 0) + #expect(result.currentFunction == nil) + #expect(result.dotPrefix == "o") + } + + @Test("A keyword inside a string does not change the clause") + func keywordInAStringIsText() { + let result = context("SELECT * FROM users WHERE name = 'x FROM y' AND ", on: "SQLite") + #expect(result.clauseType == .and) + #expect(!result.tableReferences.contains { $0.tableName == "y" }) + } + + // These grammars set backslash escapes in `"`, which the analyzer reads as a string, yet the engines quote names + // with it. + @Test("A closed double-quoted table still becomes a reference", arguments: ["ClickHouse", "DynamoDB"]) + func doubleQuotedTableUnderABackslashGrammar(engine: String) { + let query = "SELECT o. FROM \"orders\" o" + let result = context(query, on: engine, cursor: 9) + #expect(result.dotPrefix == "o") + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + } + + @Test("The quoted name at the cursor is reported with its close once there is one") + func quotedNameAtTheCursor() { + let closed = context("SELECT * FROM [Order Det]", on: "SQL Server", cursor: 24) + #expect(closed.quotedName == SQLQuotedName(open: 14, close: 24)) + #expect(closed.clauseType == .from) + + let open = context("SELECT * FROM [Order Det", on: "SQL Server") + #expect(open.quotedName == SQLQuotedName(open: 14, close: nil)) + + let afterStatement = context("SELECT 1; SELECT * FROM \"Ord\"", on: "PostgreSQL", cursor: 28) + #expect(afterStatement.quotedName == SQLQuotedName(open: 24, close: 28)) + + #expect(context("SELECT * FROM ord", on: "PostgreSQL").quotedName == nil) + } + + // A quote typed before later names pairs with the next quote, which belongs to that later text. + @Test("A close that leaves the rest of the statement unbalanced is not the name's") + func laterCloseIsNotTheName() { + let doubleQuoted = context("SELECT * FROM \"Ord WHERE \"status\" = 1", on: "PostgreSQL", cursor: 18) + #expect(doubleQuoted.quotedName == SQLQuotedName(open: 14, close: nil)) + + let bracketed = context("SELECT * FROM [Ord WHERE [status] = 1", on: "SQL Server", cursor: 18) + #expect(bracketed.quotedName == SQLQuotedName(open: 14, close: nil)) + + let spaced = context("SELECT * FROM \"Order Det\" WHERE \"status\" = 1", on: "PostgreSQL", cursor: 18) + #expect(spaced.quotedName == SQLQuotedName(open: 14, close: 24)) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift new file mode 100644 index 0000000000..ae37ded1bb --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerQuotedReferenceTests.swift @@ -0,0 +1,161 @@ +import Foundation +@testable import TableProAutocomplete +import TableProSQLGrammar +import Testing + +struct SQLContextAnalyzerQuotedReferenceTests { + private let analyzer = SQLContextAnalyzer() + + private func context(_ query: String, on engine: String, cursor: Int? = nil) -> SQLContext { + analyzer.analyze( + query: query, + cursorPosition: cursor ?? (query as NSString).length, + grammar: .engine(engine) + ) + } + + private func cursor(after marker: String, in query: String) -> Int { + let range = (query as NSString).range(of: marker) + return range.location + range.length + } + + @Test( + "A quoted table name becomes a reference", + arguments: [ + ("SELECT o. FROM [orders] o", "SQL Server", "orders", nil as String?), + ("SELECT o. FROM [dbo].[orders] o", "SQL Server", "orders", "dbo"), + ("SELECT o. FROM [orders] o", "SQLite", "orders", nil), + ("SELECT o. FROM \"public\".\"orders\" o", "PostgreSQL", "orders", "public"), + ("SELECT o. FROM \"ORDERS\" o", "Oracle", "ORDERS", nil), + ("SELECT o. FROM public.orders o", "PostgreSQL", "orders", "public"), + ("SELECT o. FROM orders o", "SQL Server", "orders", nil) + ] + ) + func quotedTableBecomesAReference(query: String, engine: String, table: String, schema: String?) { + let result = context(query, on: engine, cursor: cursor(after: "o.", in: query)) + #expect(result.dotPrefix == "o") + #expect(result.tableReferences.contains { $0.tableName == table && $0.alias == "o" && $0.schema == schema }) + } + + @Test("A reference records whether the name it goes by was quoted") + func quotedIdentifierIsRecorded() { + let query = "SELECT * FROM orders AS \"O\", \"Customers\", items i, sales.\"Audit\" a WHERE " + let references = context(query, on: "PostgreSQL").tableReferences + let quoted = Dictionary(references.map { ($0.identifier, $0.isIdentifierQuoted) }, uniquingKeysWith: { $0 || $1 }) + + #expect(quoted["O"] == true) + #expect(quoted["Customers"] == true) + #expect(quoted["i"] == false) + #expect(quoted["a"] == false) + } + + // The shape the iOS SELECT * FROM template writes on SQL Server. + @Test("A bracketed template query keeps its alias") + func bracketedTemplateKeepsItsAlias() { + let query = "SELECT * FROM [dbo].[orders] o WHERE o. ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 100 ROWS ONLY" + let result = context(query, on: "SQL Server", cursor: cursor(after: "WHERE o.", in: query)) + #expect(result.dotPrefix == "o") + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + } + + @Test("A bracketed join target becomes a reference") + func bracketedJoinTarget() { + let result = context("SELECT * FROM a JOIN [orders] o ON o.", on: "SQL Server") + #expect(result.tableReferences.contains { $0.tableName == "a" }) + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + } + + // `ORDER` inside the quoted name used to end the FROM list. + @Test( + "A name with a space or a keyword inside its quotes stays whole", + arguments: [ + ("SELECT od. FROM \"Order Details\" od", "PostgreSQL", "Order Details", "od"), + ("SELECT od. FROM `Order Details` od", "MySQL", "Order Details", "od"), + ("SELECT li. FROM \"Line Items\" li", "PostgreSQL", "Line Items", "li"), + ("SELECT od. FROM [Order Details] od", "SQL Server", "Order Details", "od") + ] + ) + func spacedNameStaysWhole(query: String, engine: String, table: String, alias: String) { + let result = context(query, on: engine, cursor: cursor(after: "\(alias).", in: query)) + #expect(result.tableReferences.contains { $0.tableName == table && $0.alias == alias }) + #expect(!result.tableReferences.contains { $0.tableName == "Line" || $0.tableName == "Order" }) + } + + @Test("A doubled quote inside a name reads as one quote") + func doubledQuoteInsideAName() { + let query = "SELECT q. FROM \"say \"\"hi\"\"\" q" + let result = context(query, on: "PostgreSQL", cursor: cursor(after: "q.", in: query)) + #expect(result.tableReferences.contains { $0.tableName == "say \"hi\"" && $0.alias == "q" }) + } + + @Test("Dollar and hash continue a bare name on Oracle") + func dollarAndHashContinueANameOnOracle() { + let query = "SELECT x. FROM SYS$T x" + let result = context(query, on: "Oracle", cursor: cursor(after: "x.", in: query)) + #expect(result.tableReferences.contains { $0.tableName == "SYS$T" && $0.alias == "x" }) + } + + @Test("A quoted qualifier gives the dot prefix") + func quotedQualifierGivesTheDotPrefix() { + let bracketed = context("SELECT * FROM [dbo].[ord", on: "SQL Server") + #expect(bracketed.dotPrefix == "dbo") + #expect(bracketed.prefix == "ord") + + let quoted = context("SELECT * FROM \"my schema\".ord", on: "PostgreSQL") + #expect(quoted.dotPrefix == "my schema") + #expect(quoted.prefix == "ord") + } + + @Test("A dot inside a quoted name is no qualifier") + func dotInsideAQuotedName() { + let query = "SELECT * FROM \"a.b\"" + let result = context(query, on: "PostgreSQL", cursor: 18) + #expect(result.dotPrefix == nil) + } + + @Test("An open bracket is part of the replaced token", arguments: ["SQL Server", "SQLite"]) + func openBracketIsReplaced(engine: String) { + let result = context("SELECT * FROM [ord", on: engine) + #expect(result.clauseType == .from) + #expect(result.prefix == "ord") + #expect(result.prefixRange == 14..<18) + } + + @Test( + "A closed pair replaces only the name between its quotes", + arguments: [ + ("SELECT * FROM [ord]", "SQL Server"), + ("SELECT * FROM \"ord\"", "PostgreSQL") + ] + ) + func closedPairReplacesTheName(query: String, engine: String) { + let result = context(query, on: engine, cursor: 18) + #expect(!result.isInsideString) + #expect(result.clauseType == .from) + #expect(result.expectsObjectName) + #expect(result.prefix == "ord") + #expect(result.prefixRange == 15..<18) + } + + @Test("SET is never an UPDATE alias") + func setIsNeverAnAlias() { + let result = context("UPDATE orders SET to", on: "PostgreSQL") + #expect(result.clauseType == .set) + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == nil }) + #expect(!result.tableReferences.contains { $0.alias == "SET" }) + } + + @Test("An UPDATE with a FROM list keeps both tables under their own names") + func updateWithFromListKeepsBothTables() { + let result = context("UPDATE orders SET total = c.x FROM customers c WHERE ", on: "PostgreSQL") + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == nil }) + #expect(result.tableReferences.contains { $0.tableName == "customers" && $0.alias == "c" }) + #expect(!result.tableReferences.contains { $0.identifier == "SET" }) + } + + @Test("An UPDATE alias still resolves") + func updateAliasStillResolves() { + let result = context("UPDATE orders o SET to", on: "PostgreSQL") + #expect(result.tableReferences.contains { $0.tableName == "orders" && $0.alias == "o" }) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift similarity index 93% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift index 71444b4d95..93e15d2f69 100644 --- a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerTests.swift @@ -5,8 +5,6 @@ import Testing struct SQLContextAnalyzerTests { let analyzer = SQLContextAnalyzer() - // MARK: - Clause Detection Tests - @Test("Detects SELECT clause at beginning") func testSelectClause() { let context = analyzer.analyze(query: "SELECT ", cursorPosition: 7) @@ -112,8 +110,6 @@ struct SQLContextAnalyzerTests { @Test("Detects SELECT clause for function call in SELECT") func testFunctionArgClause() { let context = analyzer.analyze(query: "SELECT COUNT(", cursorPosition: 13) - // The SELECT regex matches before the function-arg fallback, - // because regex-based clause detection runs before function detection #expect(context.clauseType == .select) } @@ -156,7 +152,6 @@ struct SQLContextAnalyzerTests { @Test("Detects ON clause for CREATE INDEX ... ON") func testCreateIndexClause() { let context = analyzer.analyze(query: "CREATE INDEX idx ON ", cursorPosition: 20) - // The ON regex matches before the CREATE INDEX regex (which needs a table name after ON) #expect(context.clauseType == .on) } @@ -184,8 +179,6 @@ struct SQLContextAnalyzerTests { #expect(context.clauseType == .using) } - // MARK: - Prefix Extraction Tests - @Test("Extracts partial prefix") func testPartialPrefix() { let context = analyzer.analyze(query: "SELECT us", cursorPosition: 9) @@ -212,8 +205,7 @@ struct SQLContextAnalyzerTests { #expect(context.dotPrefix == "u") } - /// The quote is part of the segment an accepted completion replaces, but no candidate's filter - /// text carries one, so leaving it in the prefix dropped every candidate and closed the popup. + // No candidate's filter text carries the quote, so keeping it in the prefix dropped every candidate. @Test( "A quoted identifier matches on the name without its quote", arguments: [ @@ -227,19 +219,27 @@ struct SQLContextAnalyzerTests { #expect(context.prefix == expected) } - /// An unterminated double quote reads as an open string literal, which is what it is on MySQL - /// and MariaDB. Every other engine quotes identifiers with it, and reaching that needs the - /// dialect the analyzer is not given, so a double-quoted identifier still completes nothing. - @Test("An unterminated double quote is still read as an open string") - func unterminatedDoubleQuoteReadsAsAString() { + @Test("An unterminated double quote opens an identifier in standard SQL") + func unterminatedDoubleQuoteIsAnIdentifierPrefix() { let query = "SELECT * FROM \"cat" let context = analyzer.analyze(query: query, cursorPosition: (query as NSString).length) + #expect(!context.isInsideString) + #expect(context.prefix == "cat") + #expect(context.prefixRange == 14..<18) + #expect(context.clauseType == .from) + } + + @Test("An unterminated double quote still opens a string on MySQL") + func unterminatedDoubleQuoteReadsAsAStringOnMySQL() { + let query = "SELECT * FROM \"cat" + let context = analyzer.analyze( + query: query, cursorPosition: (query as NSString).length, grammar: .engine("MySQL") + ) #expect(context.isInsideString) #expect(context.prefix == "") } - /// The replacement still covers the quote, so accepting a suggestion overwrites it instead of - /// leaving the opening quote stranded before the inserted name. + // Accepting a suggestion overwrites the quote instead of stranding it before the name. @Test("A quoted identifier's replacement range still starts at the quote") func quotedPrefixRangeCoversTheQuote() { let query = "SELECT * FROM `cat" @@ -282,8 +282,6 @@ struct SQLContextAnalyzerTests { #expect(context.dotPrefix != nil) } - // MARK: - Table Reference Tests - @Test("Extracts table reference from FROM") func testTableReferenceFromClause() { let context = analyzer.analyze(query: "SELECT * FROM users", cursorPosition: 19) @@ -346,8 +344,6 @@ struct SQLContextAnalyzerTests { @Test("Extracts table reference from comma-separated list") func testMultipleTablesCommaSeparated() { let context = analyzer.analyze(query: "SELECT * FROM users, orders", cursorPosition: 27) - // The FROM regex only captures the first table after FROM; - // comma-separated tables beyond the first are not captured #expect(context.tableReferences.count >= 1) } @@ -357,8 +353,6 @@ struct SQLContextAnalyzerTests { #expect(context.tableReferences.isEmpty) } - // MARK: - String/Comment Boundary Tests - @Test("Detects cursor inside string with unclosed quote") func testInsideStringUnclosed() { let context = analyzer.analyze(query: "SELECT * FROM users WHERE name = '", cursorPosition: 34) @@ -408,8 +402,6 @@ struct SQLContextAnalyzerTests { #expect(context.isInsideComment == false) } - // MARK: - CTE Tests - @Test("Extracts single CTE name") func testSingleCTEName() { let context = analyzer.analyze(query: "WITH cte AS (SELECT 1) SELECT * FROM ", cursorPosition: 37) @@ -442,8 +434,6 @@ struct SQLContextAnalyzerTests { #expect(context.tableReferences.contains { $0.tableName == "cte" }) } - // MARK: - Nesting Level Tests - @Test("Detects nesting level zero for simple query") func testNestingLevelZero() { let context = analyzer.analyze(query: "SELECT * FROM users", cursorPosition: 19) @@ -468,8 +458,6 @@ struct SQLContextAnalyzerTests { #expect(context.nestingLevel == 0) } - // MARK: - Function Context Tests - @Test("Detects function name for COUNT") func testFunctionNameCount() { let context = analyzer.analyze(query: "SELECT COUNT(", cursorPosition: 13) @@ -494,8 +482,6 @@ struct SQLContextAnalyzerTests { #expect(context.currentFunction == "COALESCE") } - // MARK: - Comma Detection Tests - @Test("Detects cursor after comma in SELECT") func testAfterCommaInSelect() { let context = analyzer.analyze(query: "SELECT a, ", cursorPosition: 10) @@ -514,34 +500,25 @@ struct SQLContextAnalyzerTests { #expect(context.isAfterComma == true) } - // MARK: - Multi-Statement Tests - @Test("Analyzes second statement in multi-statement query") func testMultiStatementAnalysis() { let context = analyzer.analyze(query: "SELECT 1; SELECT ", cursorPosition: 17) #expect(context.clauseType == .select) } - // MARK: - P0: CF-3 - SELECT clause for wildcard - @Test("SELECT with star prefix returns select context") func testSelectStarPrefix() { let context = analyzer.analyze(query: "SELECT *", cursorPosition: 8) #expect(context.clauseType == .select) - // '*' is not an identifier char, so prefix extraction yields empty string #expect(context.prefix == "") } - // MARK: - P0: CF-4 - Function context detection - @Test("SUM( detected as function context") func testSumFunctionContext() { let context = analyzer.analyze(query: "SELECT SUM(", cursorPosition: 11) #expect(context.currentFunction == "SUM") } - // MARK: - P1: HP-1 - New Clause Types - @Test("RETURNING is detected after INSERT VALUES, DELETE and UPDATE", arguments: [ "INSERT INTO users (name) VALUES ('test') RETURNING ", "DELETE FROM users WHERE id = 1 RETURNING ", @@ -610,8 +587,6 @@ struct SQLContextAnalyzerTests { #expect(context.clauseType == .createView) } - // MARK: - P1: HP-4 - Double-quote Identifier Handling - @Test("Double-quoted dot prefix extracts correctly") func testDoubleQuotedDotPrefix() { let context = analyzer.analyze(query: "SELECT \"users\".", cursorPosition: 15) @@ -625,8 +600,6 @@ struct SQLContextAnalyzerTests { #expect(context.prefix == "s") } - // MARK: - P1: HP-6 - Static Regex CTE/Table Refs - @Test("CTE with recursive keyword extracted") func testRecursiveCTE() { let query = "WITH RECURSIVE tree AS (SELECT 1) SELECT * FROM " @@ -657,11 +630,8 @@ struct SQLContextAnalyzerTests { #expect(context.tableReferences.contains { $0.tableName == "products" }) } - // MARK: - Bugfix Regressions - @Test("CREATE TABLE not detected as functionArg") func testCreateTableNotFunctionArg() { - // Regression: "CREATE TABLE test (" looked like function call "test(" let query = "CREATE TABLE test (id " let context = analyzer.analyze(query: query, cursorPosition: query.count) #expect(context.clauseType == .createTable) @@ -699,13 +669,9 @@ struct SQLContextAnalyzerTests { #expect(context.tableReferences.contains { $0.tableName == "users" }) } - // MARK: - P2: MP-3 - Schema-Qualified Names - @Test("Double dot extracts schema and table prefix") func testDoubleDotSchemaPrefix() { let context = analyzer.analyze(query: "SELECT public.users.na", cursorPosition: 22) - // After implementation, dotPrefix should contain the table reference - // The prefix should be the column part being typed #expect(context.prefix == "na") #expect(context.dotPrefix != nil) } @@ -724,11 +690,8 @@ struct SQLContextAnalyzerTests { #expect(context.dotPrefix != nil) } - // MARK: - P2: MP-10 - Block Comment Edge Cases - @Test("Nested block comments handled correctly") func testNestedBlockComments() { - // MySQL supports nested comments differently let context = analyzer.analyze(query: "/* outer /* inner */ still comment */SELECT ", cursorPosition: 43) #expect(context.isInsideComment == false) } @@ -765,8 +728,6 @@ struct SQLContextAnalyzerTests { #expect(context.isInsideComment == false) } - // MARK: - P3: LP-6 - Subquery Context Detection - @Test("A subquery in WHERE IN, in FROM and in EXISTS is a nested select", arguments: [ "SELECT * FROM users WHERE id IN (SELECT ", "SELECT * FROM (SELECT ", @@ -797,7 +758,6 @@ struct SQLContextAnalyzerTests { func testSubqueryIncludesOuterTableRefs() { let query = "SELECT * FROM users u WHERE id IN (SELECT user_id FROM orders WHERE " let context = analyzer.analyze(query: query, cursorPosition: query.count) - // Should find both inner and outer table references #expect(context.tableReferences.contains { $0.tableName == "orders" }) #expect(context.tableReferences.contains { $0.tableName == "users" }) } @@ -810,8 +770,6 @@ struct SQLContextAnalyzerTests { #expect(context.nestingLevel == 0) } - // MARK: - Schema-Qualified Table Name Tests - @Test("FROM with schema-qualified table strips schema prefix") func testFromSchemaQualifiedTable() { let query = "SELECT * FROM public.users" @@ -878,12 +836,9 @@ struct SQLContextAnalyzerTests { #expect(context.tableReferences.contains { $0.tableName == "orders" }) } - // MARK: - Parse-Ahead Table Reference Tests - @Test("Table references extracted from full statement even when cursor is before FROM") func testTableRefsExtractedAheadOfCursor() { let query = "SELECT FROM users" - // Cursor at position 7 (after "SELECT ") let context = analyzer.analyze(query: query, cursorPosition: 7) #expect(context.tableReferences.contains { $0.tableName == "users" }) } @@ -891,14 +846,11 @@ struct SQLContextAnalyzerTests { @Test("All table references extracted from full statement regardless of cursor position") func testAllTableRefsExtractedAheadOfCursor() { let query = "SELECT na FROM users JOIN orders" - // Cursor at position 9 (after "SELECT na") let context = analyzer.analyze(query: query, cursorPosition: 9) #expect(context.tableReferences.contains { $0.tableName == "users" }) #expect(context.tableReferences.contains { $0.tableName == "orders" }) } - // MARK: - Schema Segment Tests - @Test("Captures schema from schema-qualified table reference") func testSchemaQualifiedReference() { let context = analyzer.analyze(query: "SELECT * FROM sales.orders o", cursorPosition: 28) @@ -927,8 +879,6 @@ struct SQLContextAnalyzerTests { #expect(context.tableReferences.contains { $0.tableName == "orders" && $0.schema == "sales" }) } - // MARK: - Nearest-Clause Detection (multi-clause statements) - @Test("JOIN after an ON condition, or right after the keyword, detects JOIN and expects an object name", arguments: [ "SELECT * FROM a JOIN b ON a.id = b.id INNER JOIN ", "SELECT * FROM users JOIN ", @@ -969,8 +919,6 @@ struct SQLContextAnalyzerTests { #expect(context.clauseType == .select) } - // MARK: - Table-Operand Slot Detection - @Test("Cursor after a complete FROM table does not expect an object name") func testFromAfterTableDoesNotExpectObjectName() { let query = "SELECT * FROM users " @@ -987,8 +935,6 @@ struct SQLContextAnalyzerTests { #expect(context.expectsObjectName) } - // MARK: - Comma-Separated FROM List - @Test("Extracts every table from a comma-separated FROM list") func testCommaSeparatedFromTables() { let query = "SELECT * FROM users u, orders o, products WHERE " diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerWindowingTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerWindowingTests.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerWindowingTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLContextAnalyzerWindowingTests.swift diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLKeywordsTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLKeywordsTests.swift similarity index 95% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLKeywordsTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLKeywordsTests.swift index 65b72a0393..2aadee3b3c 100644 --- a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLKeywordsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLKeywordsTests.swift @@ -90,8 +90,6 @@ struct SQLKeywordsTests { "Found \(functionNames.count - uniqueNames.count) duplicate function names") } - // MARK: - P2: MP-1 - Missing SQL Keywords - @Test("Keywords contain window clause keywords") func testWindowClauseKeywords() { let windowKeywords = ["OVER", "PARTITION", "UNBOUNDED", "PRECEDING", "FOLLOWING", "CURRENT ROW"] @@ -140,8 +138,6 @@ struct SQLKeywordsTests { } } - // MARK: - P2: MP-2 - Missing Functions - @Test("Aggregate functions include STDDEV and VARIANCE") func testMissingAggregateFunctions() { let names = SQLKeywords.aggregateFunctions.map(\.name) @@ -190,11 +186,17 @@ struct SQLKeywordsTests { @Test("allFunctions includes all new functions") func testAllFunctionsUpdated() { let allNames = SQLKeywords.allFunctions.map(\.name) - // Spot check a few from different categories #expect(allNames.contains("STDDEV")) #expect(allNames.contains("EXTRACT")) #expect(allNames.contains("REGEXP_REPLACE")) #expect(allNames.contains("SIN")) #expect(allNames.contains("JSON_BUILD_OBJECT")) } + + @Test("isKeyword matches a keyword in any case and nothing else", arguments: [ + ("date", true), ("ORDER", true), ("User", false), ("orders", false), ("current row", false), + ]) + func isKeyword(word: String, expected: Bool) { + #expect(SQLKeywords.isKeyword(word) == expected) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLLexicalGrammar+Engine.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLLexicalGrammar+Engine.swift new file mode 100644 index 0000000000..2912cacbcd --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLLexicalGrammar+Engine.swift @@ -0,0 +1,7 @@ +import TableProSQLGrammar + +extension SQLLexicalGrammar { + static func engine(_ databaseTypeId: String) -> SQLLexicalGrammar { + SQLLexicalReadings.resolve(databaseTypeId: databaseTypeId, declared: nil, session: nil).execution + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift similarity index 100% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLSnippetMarkerTests.swift diff --git a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLTokenBoundaryTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLTokenBoundaryTests.swift similarity index 74% rename from apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLTokenBoundaryTests.swift rename to apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLTokenBoundaryTests.swift index e760ff5260..72afddfb5b 100644 --- a/apple/Packages/TableProMacCore/Tests/TableProAutocompleteTests/SQLTokenBoundaryTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLTokenBoundaryTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import TableProAutocomplete +import TableProSQLGrammar import Testing struct SQLTokenBoundaryTests { @@ -86,8 +87,6 @@ struct SQLTokenBoundaryTests { #expect(range == NSRange(location: 7, length: 0)) } - // MARK: - Match text - @Test( "Match text drops the identifier quotes the segment carries", arguments: [ @@ -97,6 +96,8 @@ struct SQLTokenBoundaryTests { ("\"category\"", "category"), ("`", ""), ("\"", ""), + ("[ord", "ord"), + ("[", ""), ("cat", "cat"), ("", "") ] @@ -105,7 +106,6 @@ struct SQLTokenBoundaryTests { #expect(SQLTokenBoundary.matchText(of: segment) == expected) } - /// The segment keeps its quote so the replacement covers it; only the match text drops it. @Test("Match text does not change what the segment covers") func matchTextLeavesTheSegmentAlone() { let text = "SELECT \"mess" as NSString @@ -113,10 +113,7 @@ struct SQLTokenBoundaryTests { #expect(SQLTokenBoundary.matchText(of: text.substring(from: 7)) == "mess") } - // MARK: - Non-ASCII identifiers - - /// The ASCII-only rule read these tokens as empty, so the replacement range collapsed to zero - /// length and accepting a suggestion inserted beside the typed text instead of replacing it. + // The ASCII-only rule read these as empty, so an accepted suggestion landed beside the typed text. @Test( "A non-ASCII token is replaced, not duplicated", arguments: [ @@ -169,8 +166,7 @@ struct SQLTokenBoundaryTests { } } - /// `$` opens a MongoDB pipeline stage, and `MongoContextAnalyzer` needs the token to start - /// there, so the wider rule must not adopt it. + // `MongoContextAnalyzer` needs the token to start at a pipeline stage's `$`. @Test("A dollar sign still ends the token") func dollarEndsSegment() { let text = "aggregate([{ $match" as NSString @@ -183,4 +179,47 @@ struct SQLTokenBoundaryTests { #expect(SQLTokenBoundary.segmentStart(in: "SELECT users.na" as NSString, endingAt: 15) == 13) #expect(SQLTokenBoundary.segmentStart(in: "SELECT " as NSString, endingAt: 7) == 7) } + + @Test("An open bracket starts the segment on a bracket grammar", arguments: ["SQL Server", "SQLite"]) + func openBracketStartsTheSegment(engine: String) { + let text = "SELECT * FROM [ord" as NSString + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 18, grammar: .engine(engine)) == 14) + } + + // An editor that closes the pair leaves the caret before the close, and the bare name goes between the quotes. + @Test( + "A closed quote pair starts the segment after the opening quote", + arguments: [ + ("SQL Server", "SELECT * FROM [ord]", "SELECT * FROM [orders]"), + ("SQLite", "SELECT * FROM [ord]", "SELECT * FROM [orders]"), + ("PostgreSQL", "SELECT * FROM \"ord\"", "SELECT * FROM \"orders\""), + ("MySQL", "SELECT * FROM `ord`", "SELECT * FROM `orders`") + ] + ) + func closedPairStartsAfterTheQuote(engine: String, typed: String, accepted: String) { + let text = typed as NSString + let start = SQLTokenBoundary.segmentStart(in: text, endingAt: 18, grammar: .engine(engine)) + #expect(start == 15) + let range = NSRange(location: start, length: 18 - start) + #expect(text.replacingCharacters(in: range, with: "orders") == accepted) + } + + @Test("A quote closed before the caret stays in the segment") + func quoteClosedBeforeTheCaret() { + let text = "SELECT * FROM `cat`" as NSString + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: text.length, grammar: .engine("MySQL")) == 14) + } + + @Test("Standard SQL keeps an open quote and never treats a bracket as one") + func standardSQLSegments() { + #expect(SQLTokenBoundary.segmentStart(in: "SELECT \"mess" as NSString, endingAt: 12, grammar: .ansi) == 7) + #expect(SQLTokenBoundary.segmentStart(in: "SELECT * FROM [ord" as NSString, endingAt: 18, grammar: .ansi) == 15) + #expect(SQLTokenBoundary.segmentStart(in: "SELECT * FROM `ord" as NSString, endingAt: 18, grammar: .ansi) == 14) + } + + @Test("An opening bracket ends the walk back", arguments: ["SQL Server", "SQLite"]) + func bracketEndsTheWalk(engine: String) { + let text = "SELECT a[ord" as NSString + #expect(SQLTokenBoundary.segmentStart(in: text, endingAt: text.length, grammar: .engine(engine)) == 8) + } } diff --git a/apple/Packages/TableProMacCore/Package.swift b/apple/Packages/TableProMacCore/Package.swift index 96cafb41dc..006ad8c8bc 100644 --- a/apple/Packages/TableProMacCore/Package.swift +++ b/apple/Packages/TableProMacCore/Package.swift @@ -18,7 +18,6 @@ let package = Package( ], products: [ .library(name: "TableProUtilities", targets: ["TableProUtilities"]), - .library(name: "TableProAutocomplete", targets: ["TableProAutocomplete"]), .library(name: "TableProSSHConfig", targets: ["TableProSSHConfig"]), .library(name: "TableProMCP", targets: ["TableProMCP"]), .library(name: "TableProMCPTestSupport", targets: ["TableProMCPTestSupport"]) @@ -36,14 +35,6 @@ let package = Package( path: "Sources/TableProUtilities", swiftSettings: appModuleSettings ), - .target( - name: "TableProAutocomplete", - dependencies: [ - .product(name: "TableProSQLGrammar", package: "TableProCore") - ], - path: "Sources/TableProAutocomplete", - swiftSettings: appModuleSettings - ), .target( name: "TableProSSHConfig", dependencies: [ @@ -77,15 +68,6 @@ let package = Package( path: "Tests/TableProUtilitiesTests", swiftSettings: appModuleSettings ), - .testTarget( - name: "TableProAutocompleteTests", - dependencies: [ - "TableProAutocomplete", - .product(name: "TableProSQLGrammar", package: "TableProCore") - ], - path: "Tests/TableProAutocompleteTests", - swiftSettings: appModuleSettings - ), .testTarget( name: "TableProSSHConfigTests", dependencies: [ diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift b/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift deleted file mode 100644 index a54c8cc805..0000000000 --- a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLCompletionTriggerPolicy.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -// The query editor and the raw SQL filter field both auto-trigger, so they share one answer. -public enum SQLCompletionTriggerPolicy { - /// A clause with a full column list behind it stays shut until the user types or asks for it. - /// The clauses listed here answer with a short, closed list instead, so opening one costs the - /// user nothing. A qualified name is always a closed list too, which is why a dot prefix - /// exempts the position whatever clause it sits in. - /// - /// An opening identifier quote is typed input even though it matches nothing: the analyzer - /// keeps it inside `prefixRange` so an accepted completion overwrites it, and strips it from - /// `prefix` so the matcher can work. Reading `prefix` alone reads that as an untouched - /// position and shuts the list the user just asked for by typing the quote. - public static func suppressesEmptyPrefix(_ context: SQLContext, isManualTrigger: Bool) -> Bool { - guard !isManualTrigger, - context.prefix.isEmpty, - context.prefixRange.isEmpty, - context.dotPrefix == nil else { return false } - - switch context.clauseType { - case .from, .join, .into, .set, .insertColumns, .on, - .alterTableColumn, .returning, .using, .dropObject, .createIndex, .castTarget: - return false - case .select where !context.isAfterComma: - return false - default: - return true - } - } -} diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift b/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift deleted file mode 100644 index 701e85e86f..0000000000 --- a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLContextAnalyzer.swift +++ /dev/null @@ -1,1196 +0,0 @@ -import Foundation -import os -import TableProSQLGrammar - -private let regexLogger = Logger(subsystem: "com.TablePro", category: "SQLContextAnalyzer.Regex") - -private func compileRegex(_ pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression { - if let regex = try? NSRegularExpression(pattern: pattern, options: options) { - return regex - } - regexLogger.fault("Failed to compile static regex pattern: \(pattern, privacy: .public)") - return NSRegularExpression() -} - -public enum SQLClauseType: Sendable { - case select // In SELECT list - case from // After FROM - case join // After JOIN - case on // After ON (join condition) - case where_ // After WHERE - case and // After AND/OR - case groupBy // After GROUP BY - case orderBy // After ORDER BY - case having // After HAVING - case set // After SET (UPDATE) - case into // After INTO (INSERT) - case values // After VALUES - case insertColumns // Column list in INSERT - case functionArg // Inside function parentheses - case caseExpression // Inside CASE WHEN expression - case inList // Inside IN (...) list - case limit // After LIMIT/OFFSET - case alterTable // After ALTER TABLE tablename - case alterTableColumn // After DROP/MODIFY/CHANGE/RENAME COLUMN - case createTable // Inside CREATE TABLE definition - case columnDef // Typing column data type - case returning // After RETURNING (PostgreSQL) - case union // After UNION/INTERSECT/EXCEPT - case using // After USING (JOIN ... USING) - case window // After OVER/PARTITION BY/window clause - case dropObject // After DROP TABLE/INDEX/VIEW - case createIndex // After CREATE INDEX - case createView // After CREATE VIEW - case castTarget // After a PostgreSQL :: cast operator - case unknown // Unknown or start of query -} - -public struct TableReference: Hashable, Sendable { - public let tableName: String - public let alias: String? - public let schema: String? - public let derivedColumns: [String]? - - public init(tableName: String, alias: String?, schema: String? = nil, derivedColumns: [String]? = nil) { - self.tableName = tableName - self.alias = alias - self.schema = schema - self.derivedColumns = derivedColumns - } - - public var identifier: String { - alias ?? tableName - } - - /// A derived table (subquery or CTE) carries its own column list parsed from - /// the subquery's SELECT list, rather than resolving columns from the schema. - public var isDerived: Bool { - derivedColumns != nil - } -} - -public struct SQLContext: Sendable { - public let clauseType: SQLClauseType - public let prefix: String - public let prefixRange: Range // In the original text - public let dotPrefix: String? // Table or alias before the dot, "u" in "u.name" - public let tableReferences: [TableReference] - public let isInsideString: Bool - public let isInsideComment: Bool - - public let cteNames: [String] - public let nestingLevel: Int // 0 is the main query - public let currentFunction: String? // The function whose arguments hold the cursor - public let isAfterComma: Bool - public let expectsObjectName: Bool // Cursor is in the table-operand slot of FROM/JOIN/INTO - public let comparisonColumn: String? // Set when the cursor is on the value side of a comparison - - public init( - clauseType: SQLClauseType, - prefix: String, - prefixRange: Range, - dotPrefix: String?, - tableReferences: [TableReference], - isInsideString: Bool, - isInsideComment: Bool, - cteNames: [String] = [], - nestingLevel: Int = 0, - currentFunction: String? = nil, - isAfterComma: Bool = false, - expectsObjectName: Bool = false, - comparisonColumn: String? = nil - ) { - self.clauseType = clauseType - self.prefix = prefix - self.prefixRange = prefixRange - self.dotPrefix = dotPrefix - self.tableReferences = tableReferences - self.isInsideString = isInsideString - self.isInsideComment = isInsideComment - self.cteNames = cteNames - self.nestingLevel = nestingLevel - self.currentFunction = currentFunction - self.isAfterComma = isAfterComma - self.expectsObjectName = expectsObjectName - self.comparisonColumn = comparisonColumn - } - - public func replacingTableReferences(_ references: [TableReference]) -> SQLContext { - SQLContext( - clauseType: clauseType, - prefix: prefix, - prefixRange: prefixRange, - dotPrefix: dotPrefix, - tableReferences: references, - isInsideString: isInsideString, - isInsideComment: isInsideComment, - cteNames: cteNames, - nestingLevel: nestingLevel, - currentFunction: currentFunction, - isAfterComma: isAfterComma, - expectsObjectName: expectsObjectName, - comparisonColumn: comparisonColumn - ) - } - - /// The context to rank against before the analyzer has produced one, which is the window - /// between the popup seeding itself with statement-start keywords and the first analyzed - /// request completing. It carries no clause and no tables, so ranking falls back to the - /// prefix and exact-match bonuses alone rather than not ranking at all. - public static let unanalyzed = SQLContext( - clauseType: .unknown, - prefix: "", - prefixRange: 0..<0, - dotPrefix: nil, - tableReferences: [], - isInsideString: false, - isInsideComment: false - ) -} - -public final class SQLContextAnalyzer { - // MARK: - UTF-16 Character Constants - - private static let singleQuote = UInt16(UnicodeScalar("'").value) - private static let doubleQuote = UInt16(UnicodeScalar("\"").value) - private static let backslash = UInt16(UnicodeScalar("\\").value) - private static let semicolon = UInt16(UnicodeScalar(";").value) - private static let dash = UInt16(UnicodeScalar("-").value) - private static let newline = UInt16(UnicodeScalar("\n").value) - private static let openParen = UInt16(UnicodeScalar("(").value) - private static let closeParen = UInt16(UnicodeScalar(")").value) - private static let dot = UInt16(UnicodeScalar(".").value) - private static let comma = UInt16(UnicodeScalar(",").value) - private static let space = UInt16(UnicodeScalar(" ").value) - private static let tab = UInt16(UnicodeScalar("\t").value) - private static let cr = UInt16(UnicodeScalar("\r").value) - private static let slash = UInt16(UnicodeScalar("/").value) - private static let star = UInt16(UnicodeScalar("*").value) - - // MARK: - Cached Regex Patterns (Compiled Once at Class Load) - - /// Structural clause patterns that are bounded by parentheses or anchored to a - /// leading DDL keyword, so they identify the cursor's clause unambiguously - /// regardless of what other clauses precede it. The linear DML clauses - /// (SELECT/FROM/JOIN/ON/WHERE/...) are resolved separately by a nearest-keyword - /// scan (`scanLinearClause`), because a fixed-priority regex sweep cannot tell - /// the clause nearest the cursor from one earlier in the statement. - /// ORDER MATTERS: more specific patterns must come before general ones. - private static let structuralClauseRegexes: [(regex: NSRegularExpression, clause: SQLClauseType)] = { - let patterns: [(String, SQLClauseType)] = [ - // DDL patterns (most specific first) - ("\\bADD\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+.*?\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", - .alterTableColumn), - ("\\b(?:AFTER|BEFORE)(?:\\s+\\w*)?$", .alterTableColumn), - ("\\bFIRST\\s*$", .alterTable), - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+ADD\\s+CONSTRAINT\\s+\\w*$", .alterTable), - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+ADD\\s+\\w*$", .alterTable), - ( - "\\b(?:ADD|MODIFY|CHANGE)\\s+(?:COLUMN\\s+)?[`\"']?\\w+[`\"']?\\s+\\w+(?:\\([^)]*\\))?" + - "(?:\\s+(?:NOT\\s+)?NULL|\\s+DEFAULT(?:\\s+[^\\s]+)?|\\s+AUTO_INCREMENT" + - "|\\s+UNSIGNED|\\s+COMMENT(?:\\s+'[^']*')?)*\\s*$", - .columnDef - ), - ("\\b(?:ADD|MODIFY|CHANGE)\\s+COLUMN\\s+\\w+\\s*$", .columnDef), - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+(?:DROP|MODIFY|CHANGE|RENAME)" + - "\\s+(?:COLUMN\\s+)?[`\"']?\\w*[`\"']?\\s*$", .alterTableColumn), - ("\\bALTER\\s+TABLE\\s+[`\"']?\\w+[`\"']?\\s+\\w*$", .alterTable), - ("\\bCREATE\\s+TABLE\\s+[^(]*\\([^)]*$", .createTable), - ("\\bCREATE\\s+(?:TEMPORARY\\s+)?TABLE\\s+[^;]*\\([^)]*\\)\\s*\\w*$", .createTable), - ("\\bCREATE\\s+(?:TEMPORARY\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?\\w*$", .createTable), - ("\\bDROP\\s+(?:TABLE|VIEW|INDEX)\\s+(?:IF\\s+EXISTS\\s+)?\\w*$", .dropObject), - ("\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+\\w+\\s*\\([^)]*$", .createIndex), - ("\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w*$", .createIndex), - ("\\bCREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:MATERIALIZED\\s+)?VIEW\\s+\\w+\\s+AS\\s+[^;]*$", - .createView), - ("\\bCREATE\\s+(?:OR\\s+REPLACE\\s+)?(?:MATERIALIZED\\s+)?VIEW\\s+\\w*$", .createView), - ("\\bUSING\\s*\\([^)]*$", .using), - ("\\bOVER\\s*\\([^)]*$", .window), - ("\\bPARTITION\\s+BY\\s+[^)]*$", .window), - ("\\bIN\\s*\\([^)]*$", .inList), - ("\\b(LIMIT|OFFSET)\\s+\\d*$", .limit), - ("\\bVALUES\\s*(?:\\([^)]*\\)\\s*,?\\s*)+\\w*$", .values), - ("\\bVALUES\\s*\\([^)]*$", .values), - ("\\bINSERT\\s+INTO\\s+\\w+\\s*\\([^)]*$", .insertColumns), - ] - return patterns.map { pattern, clause in - (compileRegex(pattern, options: .caseInsensitive), clause) - } - }() - - private static let singleQuoteStringRegex = compileRegex("'[^']*'") - - private static let doubleQuoteStringRegex = compileRegex("\"[^\"]*\"") - - private static let blockCommentRegex = compileRegex("/\\*[\\s\\S]*?\\*/") - - private static let lineCommentRegex = compileRegex("--[^\n]*") - - private static let stringsAndCommentsRegex = compileRegex( - #"'[^']*'|"[^"]*"|/\*[\s\S]*?\*/|--[^\n]*"# - ) - - private static let cteFirstRegex = compileRegex( - "(?i)\\bWITH\\s+(?:RECURSIVE\\s+)?([\\w]+)\\s+AS\\s*\\(" - ) - - private static let cteCommaRegex = compileRegex("(?i),\\s*([\\w]+)\\s+AS\\s*\\(") - - private static let tableRefRegexes: [NSRegularExpression] = { - let segment = "[`\"']?\\w+[`\"']?" - let path = "(\(segment)(?:\\.\(segment))*)" - let alias = "(?:\\s+(?:AS\\s+)?[`\"']?(\\w+)[`\"']?)?" - let patterns = [ - "(?i)(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\\s*(?:OUTER)?\\s*JOIN\\s+\(path)\(alias)", - "(?i)\\bUPDATE\\s+\(path)\(alias)", - "(?i)\\bINSERT\\s+INTO\\s+\(path)", - "(?i)\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+\\w+\\s+ON\\s+\(path)" - ] - return patterns.map { compileRegex($0) } - }() - - /// Captures the comma-separated table list after a FROM keyword, up to the - /// next clause keyword, opening parenthesis, or statement end. Handles - /// old-style implicit joins (`FROM a, b, c`) that the single-table pattern - /// above could not, so every listed table is in scope for column completion. - private static let fromListRegex = compileRegex( - "(?i)\\bFROM\\s+([\\s\\S]+?)" + - "(?=\\b(?:WHERE|GROUP|ORDER|HAVING|LIMIT|OFFSET|JOIN|INNER|LEFT|RIGHT" + - "|FULL|CROSS|NATURAL|ON|USING|UNION|INTERSECT|EXCEPT|RETURNING|SET" + - "|WINDOW|FETCH|FOR)\\b|[;()]|$)" - ) - - private static let derivedTableParser = DerivedTableParser() - - // MARK: - UTF-16 Helpers - - /// Check if a UTF-16 code unit is whitespace (space, tab, newline, CR) - private static func isWhitespace(_ ch: UInt16) -> Bool { - ch == space || ch == tab || ch == newline || ch == cr - } - - public init() {} - - // MARK: - Main Analysis - - public func analyze(query: String, cursorPosition: Int, grammar: SQLLexicalGrammar) -> SQLContext { - let nsQuery = query as NSString - let safePosition = min(cursorPosition, nsQuery.length) - - // Extract the current statement for multi-statement queries - let located = SQLStatementScanner.locatedStatementAtCursor( - in: nsQuery as String, cursorPosition: safePosition, grammar: grammar - ) - let currentStatement = located.sql - let statementOffset = located.offset - let adjustedPosition = safePosition - statementOffset - - let nsStatement = currentStatement as NSString - let clampedPosition = max(0, min(adjustedPosition, nsStatement.length)) - let textBeforeCursor = nsStatement.substring(to: clampedPosition) - - if isInsideString(textBeforeCursor) { - return SQLContext( - clauseType: .unknown, - prefix: "", - prefixRange: safePosition..(tableReferences) - - if let alterTableName = extractAlterTableName(from: currentStatement) { - let alterRef = TableReference(tableName: alterTableName, alias: nil) - if seenReferences.insert(alterRef).inserted { - tableReferences.append(alterRef) - } - } - - let nestingLevel = calculateNestingLevel(in: textBeforeCursor) - - let currentFunction = detectFunctionContext(in: textBeforeCursor) - - let isAfterComma = checkIfAfterComma(textBeforeCursor) - - // Clause detection runs on the text BEFORE the token being typed: the - // prefix is what completion filters on, so it cannot also count as a - // committed clause keyword ("SELECT|" must still suggest the SELECT - // keyword, not switch to select-list completions). - let nsBeforeCursor = textBeforeCursor as NSString - let textBeforePrefix = nsBeforeCursor.substring(to: min(prefixStart, nsBeforeCursor.length)) - - // For subquery context, extract text from the innermost subquery - // so clause detection works on the subquery's SQL, not the outer query - let clauseText: String - if nestingLevel > 0 { - clauseText = extractInnermostSubqueryText(from: textBeforePrefix) - } else { - clauseText = textBeforePrefix - } - - let resolution = determineClauseType( - textBeforeCursor: clauseText, - dotPrefix: dotPrefix, - currentFunction: currentFunction - ) - - let isCastTarget = endsWithCastOperator(nsBeforeCursor, before: prefixStart) - let comparisonColumn = comparisonTarget(in: nsBeforeCursor, before: prefixStart) - - return SQLContext( - clauseType: isCastTarget ? .castTarget : resolution.clause, - prefix: prefix, - prefixRange: (statementOffset + prefixStart).. [String] { - var cteNames: [String] = [] - let nsRange = NSRange(location: 0, length: (query as NSString).length) - - if let match = Self.cteFirstRegex.firstMatch(in: query, range: nsRange) { - let nameNSRange = match.range(at: 1) - if nameNSRange.location != NSNotFound { - cteNames.append((query as NSString).substring(with: nameNSRange)) - } - } - - Self.cteCommaRegex.enumerateMatches(in: query, range: nsRange) { match, _, _ in - if let match = match { - let nameNSRange = match.range(at: 1) - if nameNSRange.location != NSNotFound { - cteNames.append((query as NSString).substring(with: nameNSRange)) - } - } - } - - return cteNames - } - - // MARK: - Subquery Support - - /// Calculate the nesting level (subquery depth) at cursor position. - /// Uses NSString character-at-index for O(1) access per character. - private func calculateNestingLevel(in textBeforeCursor: String) -> Int { - let ns = textBeforeCursor as NSString - let length = ns.length - var level = 0 - var inString = false - var prevChar: UInt16 = 0 - - for i in 0.. = [ - "SELECT", "INSERT", "UPDATE", "DELETE" - ] - - /// Pre-compiled regex to detect a SQL statement keyword after opening paren - private static let subqueryDetectRegex: NSRegularExpression? = { - try? NSRegularExpression( - pattern: "^\\s*(?:SELECT|INSERT|UPDATE|DELETE)\\b", - options: .caseInsensitive - ) - }() - - /// Extract text from the innermost subquery's opening parenthesis. - /// Only extracts if the text after the paren starts with a SQL statement keyword - /// (SELECT, INSERT, UPDATE, DELETE), distinguishing subqueries from plain - /// parenthesized expressions like INSERT INTO t (col1, col2) or USING (col). - /// Uses NSString character-at-index for O(1) access per character. - private func extractInnermostSubqueryText(from textBeforeCursor: String) -> String { - let ns = textBeforeCursor as NSString - let length = ns.length - var parenStack: [Int] = [] - var inString = false - var prevChar: UInt16 = 0 - - for i in 0.. String? { - let ns = textBeforeCursor as NSString - let length = ns.length - var parenStack: [(position: Int, precedingWord: String?)] = [] - var inString = false - var prevChar: UInt16 = 0 - var wordStart = -1 // -1 means "not in a word" - var lastWord: String? - - for i in 0..= 0 { - lastWord = ns.substring(with: NSRange(location: wordStart, length: i - wordStart)) - wordStart = -1 - } - - if ch == Self.openParen { - parenStack.append((position: i, precedingWord: lastWord)) - lastWord = nil - } else if ch == Self.closeParen { - if !parenStack.isEmpty { - parenStack.removeLast() - } - } - } - } - - prevChar = ch - } - - // Flush trailing word (cursor is immediately after an identifier) - if wordStart >= 0 { - lastWord = ns.substring(with: NSRange(location: wordStart, length: length - wordStart)) - } - - if let lastParen = parenStack.last, - let funcName = lastParen.precedingWord { - let upperFunc = funcName.uppercased() - let sqlFunctions: Set = [ - "COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "IFNULL", - "CONCAT", "SUBSTRING", "UPPER", "LOWER", "NOW", "DATE", - "CAST", "CONVERT", "ROUND", "ABS", "LENGTH", "TRIM", - "GROUP_CONCAT", "DATE_FORMAT", "YEAR", "MONTH", "DAY" - ] - - let subqueryKeywords: Set = [ - "SELECT", "FROM", "WHERE", "IN", "EXISTS", "NOT" - ] - - if sqlFunctions.contains(upperFunc) || - !subqueryKeywords.contains(upperFunc) { - return funcName - } - } - - return nil - } - - // MARK: - Comma Detection - - /// Check if the cursor is immediately after a comma (for multi-column contexts). - /// Scans backwards using NSString for O(1) character access. - private func checkIfAfterComma(_ text: String) -> Bool { - let ns = text as NSString - let length = ns.length - var i = length - 1 - while i >= 0 { - let ch = ns.character(at: i) - if Self.isWhitespace(ch) { - i -= 1 - continue - } - return ch == Self.comma - } - return false - } - - // MARK: - Helper Methods - - /// Check if cursor is inside a string literal. - /// Uses NSString character-at-index for O(1) access per character. - private func isInsideString(_ text: String) -> Bool { - let ns = text as NSString - let length = ns.length - var inSingleQuote = false - var inDoubleQuote = false - var prevChar: UInt16 = 0 - - for i in 0.. Bool { - let ns = text as NSString - let length = ns.length - guard length > 0 else { return false } - - // Single-pass state machine scanning for block/line comments (SVC-14) - var blockDepth = 0 - var lastBlockEnd = -1 // position after the last */ that closed depth to 0 - var idx = 0 - - while idx < length { - let ch = ns.character(at: idx) - - if blockDepth > 0 { - // Inside a block comment, look for */ - if ch == 0x2A /* * */ && idx + 1 < length && ns.character(at: idx + 1) == 0x2F /* / */ { - blockDepth -= 1 - if blockDepth == 0 { - lastBlockEnd = idx + 2 - } - idx += 2 - continue - } - } else { - // Outside block comment - if ch == 0x2F /* / */ && idx + 1 < length && ns.character(at: idx + 1) == 0x2A /* * */ { - blockDepth += 1 - idx += 2 - continue - } - } - idx += 1 - } - - // If we're still inside an unclosed block comment, cursor is in a comment - if blockDepth > 0 { - return true - } - - // Check for line comment on the last line, ignoring text inside block comments. - // The scan for "--" must start from whichever is later: the position after - // the last newline or the position after the last block comment close ("*/"). - // This prevents "--" inside a closed block comment from being misdetected. - let fullRange = NSRange(location: 0, length: length) - let lastNewlineRange = ns.range(of: "\n", options: .backwards, range: fullRange) - - let lastNewlineLocation: Int - if lastNewlineRange.location != NSNotFound { - lastNewlineLocation = lastNewlineRange.location + 1 - } else { - lastNewlineLocation = 0 - } - - let lineStart = max(lastNewlineLocation, max(lastBlockEnd, 0)) - - guard lineStart < length else { return false } - - let lineRange = NSRange(location: lineStart, length: length - lineStart) - let currentLine = ns.substring(with: lineRange) - let nsLine = currentLine as NSString - let dashRange = nsLine.range(of: "--") - if dashRange.location != NSNotFound { - let before = nsLine.substring(to: dashRange.location) - if !isInsideString(before) { - return true - } - } - - return false - } - - /// The column a value is being compared against, when the cursor sits on the value side of a - /// comparison. Drives value completion for columns with a known set of allowed values. - private func comparisonTarget(in text: NSString, before prefixStart: Int) -> String? { - var index = skippingWhitespaceBackwards(in: text, from: min(prefixStart, text.length)) - guard let operatorStart = comparisonOperatorStart(in: text, endingAt: index) else { return nil } - - index = skippingWhitespaceBackwards(in: text, from: operatorStart) - let identifierEnd = index - while index > 0, SQLTokenBoundary.isIdentifierChar(text.character(at: index - 1)) { - index -= 1 - } - guard index < identifierEnd else { return nil } - - let identifier = text.substring(with: NSRange(location: index, length: identifierEnd - index)) - guard !Self.comparisonStopWords.contains(identifier.uppercased()) else { return nil } - return identifier - } - - private func skippingWhitespaceBackwards(in text: NSString, from start: Int) -> Int { - var index = start - while index > 0 { - let character = text.character(at: index - 1) - guard character == Self.space || character == Self.tab - || character == Self.newline || character == Self.cr else { break } - index -= 1 - } - return index - } - - private func comparisonOperatorStart(in text: NSString, endingAt end: Int) -> Int? { - for symbol in Self.comparisonOperators { - let length = (symbol as NSString).length - guard end >= length else { continue } - let range = NSRange(location: end - length, length: length) - guard text.substring(with: range).caseInsensitiveCompare(symbol) == .orderedSame else { continue } - - let start = end - length - let isWordOperator = symbol.first?.isLetter == true - if isWordOperator, start > 0, SQLTokenBoundary.isIdentifierChar(text.character(at: start - 1)) { - continue - } - return start - } - return nil - } - - private static let comparisonOperators: [String] = ["<>", "!=", "<=", ">=", "=", "<", ">", "IN", "LIKE", "ILIKE"] - - private static let comparisonStopWords: Set = [ - "AND", "OR", "NOT", "WHERE", "ON", "HAVING", "SELECT", "SET", "WHEN", "THEN", "ELSE", "BY" - ] - - /// True when the token being typed directly follows a PostgreSQL `::` cast operator. - private func endsWithCastOperator(_ text: NSString, before prefixStart: Int) -> Bool { - guard prefixStart >= 2, prefixStart <= text.length else { return false } - let colon = UInt16(UnicodeScalar(":").value) - return text.character(at: prefixStart - 1) == colon && text.character(at: prefixStart - 2) == colon - } - - /// Extract the current word prefix and any dot prefix (table.column). - private func extractPrefix( - from text: String - ) -> (prefix: String, start: Int, dotPrefix: String?) { - let ns = text as NSString - let length = ns.length - guard length > 0 else { - return ("", 0, nil) - } - - let start = SQLTokenBoundary.segmentStart(in: ns, endingAt: length) - let prefix = SQLTokenBoundary.matchText(of: ns.substring(from: start)) - - guard start > 0, ns.character(at: start - 1) == Self.dot else { - return (prefix, start, nil) - } - - let qualifierEnd = start - 1 - let qualifierStart = SQLTokenBoundary.segmentStart(in: ns, endingAt: qualifierEnd) - guard qualifierStart < qualifierEnd else { - return (prefix, start, nil) - } - - let qualifierRange = NSRange(location: qualifierStart, length: qualifierEnd - qualifierStart) - let dotPrefix = SQLTokenBoundary.matchText(of: ns.substring(with: qualifierRange)) - return (prefix, start, dotPrefix) - } - - private static let tableRefKeywords: Set = [ - "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "NATURAL", - "JOIN", "ON", "AND", "OR", "WHERE", "SELECT", "FROM", "AS" - ] - - private static let bareIdentifierRegex = compileRegex("^\\w+$") - - private static func isBareIdentifier(_ value: String) -> Bool { - let range = NSRange(location: 0, length: (value as NSString).length) - return bareIdentifierRegex.firstMatch(in: value, range: range) != nil - } - - private static let identifierQuoteChars = CharacterSet(charactersIn: "`\"'") - - /// Extract all table references (table names and aliases) from the query - private func extractTableReferences(from query: String) -> [TableReference] { - var references: [TableReference] = [] - var seen = Set() - - appendFromListReferences(from: query, into: &references, seen: &seen) - - let nsRange = NSRange(location: 0, length: (query as NSString).length) - - for regex in Self.tableRefRegexes { - regex.enumerateMatches(in: query, range: nsRange) { match, _, _ in - guard let match = match else { return } - - let tableNSRange = match.range(at: 1) - guard tableNSRange.location != NSNotFound else { return } - - let rawName = (query as NSString).substring(with: tableNSRange) - let segments = rawName.split(separator: ".").map { - String($0).trimmingCharacters(in: Self.identifierQuoteChars) - } - guard let tableName = segments.last, !tableName.isEmpty else { return } - guard !Self.tableRefKeywords.contains(tableName.uppercased()) else { return } - - let schema = segments.count >= 2 ? segments[segments.count - 2] : nil - - var alias: String? - if match.numberOfRanges > 2 { - let aliasNSRange = match.range(at: 2) - if aliasNSRange.location != NSNotFound { - let aliasCandidate = (query as NSString).substring( - with: aliasNSRange - ) - if !Self.tableRefKeywords.contains(aliasCandidate.uppercased()) { - alias = aliasCandidate - } - } - } - - let ref = TableReference(tableName: tableName, alias: alias, schema: schema) - if seen.insert(ref).inserted { - references.append(ref) - } - } - } - - return references - } - - /// Attach derived-table columns to references and add any derived table or - /// CTE not already in scope. Columns attach to a matching reference (e.g. a - /// CTE aliased in a FROM clause), and the derived table or CTE is still - /// registered under its own name so it resolves by either identifier. - private func mergeDerivedTables( - _ derivedTables: [DerivedTable], - cteNames: [String], - into references: inout [TableReference] - ) { - let derivedColumnsByAlias = Dictionary( - derivedTables.map { ($0.alias.lowercased(), $0.columns) }, - uniquingKeysWith: { first, _ in first } - ) - - for index in references.indices { - let ref = references[index] - for key in [ref.tableName.lowercased(), ref.identifier.lowercased()] { - guard let columns = derivedColumnsByAlias[key] else { continue } - references[index] = TableReference( - tableName: ref.tableName, alias: ref.alias, schema: ref.schema, derivedColumns: columns - ) - break - } - } - - var present = Set(references.map { $0.identifier.lowercased() }) - for derived in derivedTables where present.insert(derived.alias.lowercased()).inserted { - references.append( - TableReference(tableName: derived.alias, alias: derived.alias, derivedColumns: derived.columns) - ) - } - for cteName in cteNames where present.insert(cteName.lowercased()).inserted { - references.append(TableReference(tableName: cteName, alias: nil)) - } - } - - /// Parse each comma-separated entry in a FROM list into a table reference. - private func appendFromListReferences( - from query: String, - into references: inout [TableReference], - seen: inout Set - ) { - let nsQuery = query as NSString - let nsRange = NSRange(location: 0, length: nsQuery.length) - Self.fromListRegex.enumerateMatches(in: query, range: nsRange) { match, _, _ in - guard let match = match else { return } - let listRange = match.range(at: 1) - guard listRange.location != NSNotFound else { return } - - let listText = nsQuery.substring(with: listRange) - for entry in listText.split(separator: ",") { - guard let ref = self.tableReference(fromListEntry: String(entry)) else { continue } - if seen.insert(ref).inserted { - references.append(ref) - } - } - } - } - - /// Parse a single `table [AS] alias` entry from a FROM list. - private func tableReference(fromListEntry entry: String) -> TableReference? { - let tokens = entry - .split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" }) - .map(String.init) - guard let pathToken = tokens.first else { return nil } - - let segments = pathToken.split(separator: ".").map { - String($0).trimmingCharacters(in: Self.identifierQuoteChars) - } - guard let tableName = segments.last, !tableName.isEmpty else { return nil } - // Reject anything that is not a plain identifier path, so a derived-table - // subquery (`FROM (SELECT ...) x`) never yields a phantom table name. - guard segments.allSatisfy(Self.isBareIdentifier) else { return nil } - guard !Self.tableRefKeywords.contains(tableName.uppercased()) else { return nil } - - let schema = segments.count >= 2 ? segments[segments.count - 2] : nil - - var alias: String? - if tokens.count >= 2 { - var candidate = tokens[1] - if candidate.uppercased() == "AS", tokens.count >= 3 { - candidate = tokens[2] - } - let cleaned = candidate.trimmingCharacters(in: Self.identifierQuoteChars) - if !cleaned.isEmpty, !Self.tableRefKeywords.contains(cleaned.uppercased()) { - alias = cleaned - } - } - - return TableReference(tableName: tableName, alias: alias, schema: schema) - } - - /// Pre-compiled regex for extracting table name from ALTER TABLE statements - private static let alterTableRegex: NSRegularExpression? = { - let pattern = "(?i)\\bALTER\\s+TABLE\\s+[`\"']?(\\w+)[`\"']?" - return try? NSRegularExpression(pattern: pattern) - }() - - /// Extract table name from ALTER TABLE statement - private func extractAlterTableName(from query: String) -> String? { - guard let regex = Self.alterTableRegex else { return nil } - - let nsRange = NSRange(location: 0, length: (query as NSString).length) - if let match = regex.firstMatch(in: query, range: nsRange) { - let tableNSRange = match.range(at: 1) - if tableNSRange.location != NSNotFound { - return (query as NSString).substring(with: tableNSRange) - } - } - - return nil - } - - /// A resolved clause plus whether the cursor sits in a table-operand slot. - private struct ClauseResolution { - let clause: SQLClauseType - let expectsObjectName: Bool - } - - /// Determine the clause type based on text before cursor - private func determineClauseType( - textBeforeCursor: String, - dotPrefix: String?, - currentFunction: String? = nil - ) -> ClauseResolution { - // If we have a dot prefix, we're looking for columns - if dotPrefix != nil { - return ClauseResolution(clause: .select, expectsObjectName: false) - } - - // Window to last N chars to avoid O(n) work on large queries - let windowSize = 5_000 // Also referenced by SQLContextAnalyzerWindowingTests - let nsText = textBeforeCursor as NSString - let windowedText: String - if nsText.length > windowSize { - windowedText = nsText.substring(from: nsText.length - windowSize) - } else { - windowedText = textBeforeCursor - } - - let cleaned = removeStringsAndComments(from: windowedText) - - // Structural / DDL contexts FIRST. These are anchored to a leading keyword - // or bounded by parentheses, so they take priority over function-arg - // detection (`CREATE TABLE test (id ` looks like a function call `test(` - // but is a column definition) and over the linear-clause scan. - let range = NSRange(location: 0, length: (cleaned as NSString).length) - for (regex, clause) in Self.structuralClauseRegexes { - if regex.firstMatch(in: cleaned, range: range) != nil { - return ClauseResolution(clause: clause, expectsObjectName: false) - } - } - - // Linear DML clauses: resolve by the clause keyword nearest the cursor, - // not by a fixed priority order. This is what lets `... ON a = b JOIN |` - // detect JOIN (suggest tables) instead of the earlier ON. - if let resolution = scanLinearClause(in: cleaned) { - return resolution - } - - // If inside a function call and no stronger clause matched, return - // function arg context - if currentFunction != nil { - return ClauseResolution(clause: .functionArg, expectsObjectName: false) - } - - return ClauseResolution(clause: .unknown, expectsObjectName: false) - } - - // MARK: - Linear Clause Scan - - private enum ScanTokenKind { - case word - case openParen - case closeParen - case comma - case other - } - - private struct ScanToken { - let kind: ScanTokenKind - let upper: String - } - - /// Find the clause introduced by the keyword nearest the cursor. - /// Scans the cleaned text-before-cursor backwards, skipping content inside - /// closed parentheses and balanced `CASE … END`, and passing transparently - /// through an enclosing open parenthesis (a function call or grouping) so the - /// governing clause is still found. Returns nil when no clause keyword governs - /// the cursor (caller falls back to function-arg or unknown). - private func scanLinearClause(in cleaned: String) -> ClauseResolution? { - let tokens = tokenize(cleaned) - var depth = 0 - var pendingEnd = 0 - var sawObject = false - var slotClosed = false - - for index in stride(from: tokens.count - 1, through: 0, by: -1) { - let token = tokens[index] - switch token.kind { - case .closeParen: - depth += 1 - case .openParen: - if depth > 0 { depth -= 1 } - case .other: - continue - case .comma: - // A comma at the cursor's depth closes the current operand slot, - // so a later table in a `FROM a, b` list still expects a name. - if depth == 0 { slotClosed = true } - case .word: - guard depth == 0 else { continue } - let word = token.upper - if word == "END" { - pendingEnd += 1 - slotClosed = true - continue - } - if word == "CASE" { - if pendingEnd > 0 { - pendingEnd -= 1 - slotClosed = true - continue - } - return ClauseResolution(clause: .caseExpression, expectsObjectName: false) - } - guard pendingEnd == 0 else { continue } - let previous = index > 0 ? tokens[index - 1].upper : nil - if let clause = clause(forKeyword: word, previous: previous, sawObject: sawObject) { - return clause - } - if !slotClosed { sawObject = true } - } - } - - return nil - } - - /// Map a clause-introducing keyword to its clause. `sawObject` is true when a - /// non-keyword identifier already appeared between this keyword and the cursor, - /// which means a table/operand was typed and the cursor is past the operand slot. - /// Returns nil for any word that does not introduce a linear clause. - private func clause(forKeyword word: String, previous: String?, sawObject: Bool) -> ClauseResolution? { - switch word { - case "WHEN", "THEN", "ELSE": - return ClauseResolution(clause: .caseExpression, expectsObjectName: false) - case "SELECT": - return ClauseResolution(clause: .select, expectsObjectName: false) - case "FROM": - return ClauseResolution(clause: .from, expectsObjectName: !sawObject) - case "JOIN": - return ClauseResolution(clause: .join, expectsObjectName: !sawObject) - case "INTO": - return ClauseResolution(clause: .into, expectsObjectName: !sawObject) - case "ON": - return ClauseResolution(clause: .on, expectsObjectName: false) - case "WHERE": - return ClauseResolution(clause: .where_, expectsObjectName: false) - case "AND", "OR": - return ClauseResolution(clause: .and, expectsObjectName: false) - case "HAVING": - return ClauseResolution(clause: .having, expectsObjectName: false) - case "SET": - return ClauseResolution(clause: .set, expectsObjectName: false) - case "RETURNING": - return ClauseResolution(clause: .returning, expectsObjectName: false) - case "UNION", "INTERSECT", "EXCEPT": - return ClauseResolution(clause: .union, expectsObjectName: false) - case "BY": - switch previous { - case "GROUP": - return ClauseResolution(clause: .groupBy, expectsObjectName: false) - case "ORDER": - return ClauseResolution(clause: .orderBy, expectsObjectName: false) - case "PARTITION": - return ClauseResolution(clause: .window, expectsObjectName: false) - default: - return nil - } - default: - return nil - } - } - - /// Tokenize text into words, parentheses, commas, and other separators. - /// Words are uppercased for case-insensitive keyword matching. - /// Uses NSString character-at-index for O(1) access per character. - private func tokenize(_ text: String) -> [ScanToken] { - let ns = text as NSString - let length = ns.length - var tokens: [ScanToken] = [] - var wordStart = -1 - - func flushWord(end: Int) { - guard wordStart >= 0 else { return } - let word = ns.substring(with: NSRange(location: wordStart, length: end - wordStart)) - tokens.append(ScanToken(kind: .word, upper: word.uppercased())) - wordStart = -1 - } - - for i in 0.. String { - // Single-pass replacement using combined alternation regex (SVC-13) - let ns = text as NSString - let fullRange = NSRange(location: 0, length: ns.length) - let matches = Self.stringsAndCommentsRegex.matches(in: text, range: fullRange) - - guard !matches.isEmpty else { return text } - - let mutable = NSMutableString(string: text) - - // Process in reverse to maintain valid indices - for match in matches.reversed() { - let matchRange = match.range - let matched = ns.substring(with: matchRange) - if matched.hasPrefix("'") { - mutable.replaceCharacters(in: matchRange, with: "''") - } else if matched.hasPrefix("\"") { - mutable.replaceCharacters(in: matchRange, with: "\"\"") - } else { - mutable.replaceCharacters(in: matchRange, with: "") - } - } - - return mutable as String - } -} diff --git a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift b/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift deleted file mode 100644 index 3d1900102e..0000000000 --- a/apple/Packages/TableProMacCore/Sources/TableProAutocomplete/SQLTokenBoundary.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -// The context analyzer and the completion adapter must agree on where the token under the cursor starts. -public enum SQLTokenBoundary { - private static let dot = UInt16(UnicodeScalar(".").value) - private static let backtick = UInt16(UnicodeScalar("`").value) - private static let doubleQuote = UInt16(UnicodeScalar("\"").value) - private static let underscore = UInt16(UnicodeScalar("_").value) - - /// Scalars outside ASCII that an identifier may contain. Every engine TablePro speaks accepts - /// letters beyond ASCII in an identifier, quoted or (on MySQL, PostgreSQL and SQLite) bare, so - /// an ASCII-only rule read `SELECT 名` as an empty token and accepting a suggestion inserted - /// beside the typed text rather than replacing it. `$` is deliberately absent: it opens a - /// MongoDB pipeline stage, whose own analyzer relies on the token starting there. - private static let nonASCIIIdentifierScalars: CharacterSet = { - var set = CharacterSet.letters - set.formUnion(.decimalDigits) - set.formUnion(.nonBaseCharacters) - return set - }() - - static func isIdentifierChar(_ ch: UInt16) -> Bool { - if (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) { return true } - if ch >= 0x30 && ch <= 0x39 { return true } - return ch == underscore - } - - static func isTokenChar(_ ch: UInt16) -> Bool { - isIdentifierChar(ch) || ch == backtick || ch == doubleQuote - } - - private static let identifierQuotes = CharacterSet(charactersIn: "`\"") - - /// The text a segment is matched on, which is not the text it replaces. - /// - /// ``segmentStart(in:endingAt:)`` deliberately keeps an opening quote inside the segment so an - /// accepted completion overwrites it, but no candidate's filter text carries one, and the - /// matcher needs every character of the pattern to appear in the target. Leaving the quote in - /// dropped every candidate, keyword items included, so a quoted identifier completed nothing. - public static func matchText(of segment: String) -> String { - segment.trimmingCharacters(in: identifierQuotes) - } - - /// Start of the identifier segment ending at `cursor`, scanning backward - /// over identifier and quote characters and stopping at a dot, so a - /// qualified name like `schema.tab` resolves to the segment after the dot. - /// - /// The walk steps by composed character sequence rather than by UTF-16 unit, so a surrogate - /// pair and a base character with its combining marks are each tested and consumed whole. - /// ASCII input takes the single-unit path and behaves exactly as it did. - public static func segmentStart(in text: NSString, endingAt cursor: Int) -> Int { - let clamped = min(max(cursor, 0), text.length) - var start = clamped - while start > 0 { - let unit = text.character(at: start - 1) - if unit < 0x80 { - guard isTokenChar(unit) else { break } - start -= 1 - continue - } - let sequence = text.rangeOfComposedCharacterSequence(at: start - 1) - guard sequence.location + sequence.length <= start, - isNonASCIIIdentifierSequence(text.substring(with: sequence)) else { break } - start = sequence.location - } - return start - } - - private static func isNonASCIIIdentifierSequence(_ sequence: String) -> Bool { - guard !sequence.isEmpty else { return false } - return sequence.unicodeScalars.allSatisfy { nonASCIIIdentifierScalars.contains($0) } - } - - /// Replacement range for an accepted completion: the live segment under - /// the cursor when a cursor is available, otherwise the stored range - /// computed when the suggestion window opened. - static func replacementRange(in text: NSString?, cursor: Int?, fallback: NSRange) -> NSRange { - guard let text, let cursor, cursor >= 0, cursor <= text.length else { return fallback } - let start = segmentStart(in: text, endingAt: cursor) - return NSRange(location: start, length: cursor - start) - } -} diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift new file mode 100644 index 0000000000..b694b259ed --- /dev/null +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift @@ -0,0 +1,47 @@ +import Foundation +import TableProAutocomplete +import TableProPluginKit +import TableProSQLGrammar + +extension SQLCompletionProvider { + convenience init( + schemaProvider: SQLSchemaProvider?, + databaseType: DatabaseType? = nil, + dialect: SQLDialectDescriptor? = nil, + statementCompletions: [CompletionEntry] = [] + ) { + self.init( + catalog: schemaProvider, + grammar: databaseType?.lexicalGrammar ?? .ansi, + vocabulary: SQLCompletionVocabulary(dialect: dialect, statementCompletions: statementCompletions) + ) + } + + func setDatabaseType( + _ type: DatabaseType, + dialect: SQLDialectDescriptor? = nil, + statementCompletions: [CompletionEntry] = [] + ) { + update( + grammar: type.lexicalGrammar, + vocabulary: SQLCompletionVocabulary(dialect: dialect, statementCompletions: statementCompletions) + ) + } +} + +extension SQLCompletionVocabulary { + init(dialect: SQLDialectDescriptor?, statementCompletions: [CompletionEntry]) { + self.init( + functions: dialect?.functions ?? [], + functionNamesAreCaseInsensitive: dialect?.functionNamesAreCaseInsensitive ?? false, + operators: dialect?.operators.map { + Operator(symbol: $0.symbol, summary: $0.summary, appliesToTypes: $0.appliesToTypes) + } ?? [], + dataTypes: dialect?.dataTypes ?? [], + tableOptions: dialect?.tableOptions, + statementCompletions: statementCompletions.map { + StatementCompletion(label: $0.label, insertText: $0.insertText) + } + ) + } +} diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift index c8d41048f9..109f262927 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -1,14 +1,14 @@ import Foundation import TableProAutocomplete import TableProPluginKit +import TableProSQLGrammar @MainActor final class SQLCompletionService: QueryCompletionService { private let engine: CompletionEngine + private let grammar: SQLLexicalGrammar private var lastContext = SQLContext.unanalyzed - private static let windowRadius = 5_000 - init( schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, @@ -25,12 +25,12 @@ final class SQLCompletionService: QueryCompletionService { dialect: dialect, statementCompletions: statementCompletions ) + self.grammar = databaseType?.lexicalGrammar ?? .ansi } var triggerCharacters: Set { [".", " ", ":", "(", ","] } - /// Read per request rather than captured, so changing the setting applies to the next - /// keystroke instead of the next connection. + // Read per request, so a changed setting applies to the next keystroke rather than the next connection. private var keywordCase: SQLKeywordCase { AppSettingsManager.shared.editor.keywordCase } func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { @@ -38,17 +38,11 @@ final class SQLCompletionService: QueryCompletionService { } func tokenStart(in text: NSString, endingAt offset: Int) -> Int { - SQLTokenBoundary.segmentStart(in: text, endingAt: offset) + SQLTokenBoundary.segmentStart(in: text, endingAt: offset, grammar: grammar) } - /// The incremental path reads its own prefix off the live token, which carries an opening - /// identifier quote, so it takes the same match text the analyzer resolves for a fresh request. - /// - /// A token that is nothing but quotes declines instead of widening to every candidate: a - /// re-rank cannot tell an identifier quote from the opening of a string, which `"` is on - /// MySQL, and matching everything there would hold the popup open inside a string literal. - /// Declining closes it, and the next character asks the analyzer, which reads the quote in - /// its own context. + // A quotes-only token declines instead of matching everything: a re-rank cannot tell an identifier quote from a + // MySQL string's `"`, and the next character asks the analyzer, which can. func rank(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { let matchText = SQLTokenBoundary.matchText(of: prefix) guard !matchText.isEmpty || prefix.isEmpty else { return [] } @@ -62,15 +56,13 @@ final class SQLCompletionService: QueryCompletionService { } func completions(in text: NSString, at offset: Int, isManualTrigger: Bool) async -> QueryCompletionSession? { - guard !suppressesAfterStatementBreak(in: text, at: offset) else { return nil } + guard !SQLCompletionTriggerPolicy.suppressesAfterStatementBreak(in: text, at: offset) else { return nil } - let windowStart = max(0, offset - Self.windowRadius) - let windowEnd = min(text.length, offset + Self.windowRadius) - let window = text.substring(with: NSRange(location: windowStart, length: windowEnd - windowStart)) + let window = SQLCompletionWindow.cut(text, around: offset) guard let context = await engine.getCompletions( - text: window, - cursorPosition: offset - windowStart, + text: window.text, + cursorPosition: offset - window.start, keywordCase: keywordCase ) else { return nil @@ -85,21 +77,9 @@ final class SQLCompletionService: QueryCompletionService { items: context.items, candidates: context.candidates, replacementRange: NSRange( - location: context.replacementRange.location + windowStart, + location: context.replacementRange.location + window.start, length: context.replacementRange.length ) ) } - - private func suppressesAfterStatementBreak(in text: NSString, at offset: Int) -> Bool { - guard offset > 0, offset - 1 < text.length else { return false } - - let previous = text.character(at: offset - 1) - let semicolon = UInt16(UnicodeScalar(";").value) - let newline = UInt16(UnicodeScalar("\n").value) - guard previous == semicolon || previous == newline else { return false } - guard offset < text.length else { return true } - - return text.substring(from: offset).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } } diff --git a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider+CompletionCatalog.swift b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider+CompletionCatalog.swift new file mode 100644 index 0000000000..eb4fab561f --- /dev/null +++ b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider+CompletionCatalog.swift @@ -0,0 +1,3 @@ +import TableProAutocomplete + +extension SQLSchemaProvider: SQLCompletionCatalog {} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a31291cea9..33f8b5acfe 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -21750,6 +21750,8 @@ } }, "All columns" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -53478,6 +53480,8 @@ }, "Count all rows" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { diff --git a/apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift b/apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift new file mode 100644 index 0000000000..324c1f44b3 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Completion/CompletionIdentifierQuoting.swift @@ -0,0 +1,84 @@ +import Foundation +import TableProAutocomplete +import TableProModels + +nonisolated enum CompletionIdentifierQuoting { + static func quoted(_ name: String, for type: DatabaseType) -> String { + needsQuoting(name, for: type) ? SQLBuilder.quoteIdentifier(name, for: type) : name + } + + // An alias or a table the list does not hold comes from the statement, so its case is the one the user wrote + // and re-quoting it for case would point at a different name on PostgreSQL and Oracle. + static func quotedAsWritten(_ name: String, for type: DatabaseType) -> String { + hasBareSpelling(name, for: type) ? name : SQLBuilder.quoteIdentifier(name, for: type) + } + + static func needsQuoting(_ name: String, for type: DatabaseType) -> Bool { + guard hasBareSpelling(name, for: type) else { return true } + // The list is every engine's keywords together: a quoted `date` only reads noisier, a bare reserved word fails. + if SQLKeywords.isKeyword(name) || CompletionReservedWords.contains(name, for: type) { return true } + switch type { + case .postgresql, .redshift: + return name != name.lowercased() + case .oracle: + return name != name.uppercased() + default: + return false + } + } + + private static func hasBareSpelling(_ name: String, for type: DatabaseType) -> Bool { + let scalars = Array(name.unicodeScalars) + guard !scalars.isEmpty else { return false } + if SQLBuilder.speaksMySQLDialect(type) { + guard !scalars.allSatisfy(isDigit), !readsAsMySQLNumber(scalars) else { return false } + return scalars.allSatisfy(isMySQLBare) + } + return scalars.enumerated().allSatisfy { index, scalar in + isBare(scalar, leading: index == 0, for: type) + } + } + + // `1e3`, `0x1F` and `0b101` read as numbers before they read as names. + private static func readsAsMySQLNumber(_ scalars: [Unicode.Scalar]) -> Bool { + let digits = scalars.prefix(while: isDigit).count + guard digits > 0, digits < scalars.count else { return false } + let next = scalars[digits] + if next == "e" || next == "E" { return true } + return digits == 1 && scalars[0] == "0" && (next == "x" || next == "X" || next == "b" || next == "B") + } + + private static func isMySQLBare(_ scalar: Unicode.Scalar) -> Bool { + if scalar.isASCII { + return isASCIILetter(scalar) || isDigit(scalar) || scalar == "_" || scalar == "$" + } + return scalar.value <= 0xFFFF + } + + private static func isBare(_ scalar: Unicode.Scalar, leading: Bool, for type: DatabaseType) -> Bool { + let letter = isASCIILetter(scalar) + let digit = isDigit(scalar) + switch type { + case .postgresql, .redshift: + let unicodeLetter = letter || (!scalar.isASCII && CharacterSet.letters.contains(scalar)) + return unicodeLetter || scalar == "_" || (!leading && (digit || scalar == "$")) + case .mssql: + let unicodeLetter = letter || (!scalar.isASCII && CharacterSet.letters.contains(scalar)) + return unicodeLetter || scalar == "_" || (!leading && (digit || scalar == "@" || scalar == "#" || scalar == "$")) + case .sqlite: + return letter || scalar == "_" || !scalar.isASCII || (!leading && (digit || scalar == "$")) + case .oracle: + return letter || (!leading && (digit || scalar == "_" || scalar == "$" || scalar == "#")) + default: + return letter || scalar == "_" || (!leading && digit) + } + } + + private static func isASCIILetter(_ scalar: Unicode.Scalar) -> Bool { + (0x41...0x5A).contains(scalar.value) || (0x61...0x7A).contains(scalar.value) + } + + private static func isDigit(_ scalar: Unicode.Scalar) -> Bool { + (0x30...0x39).contains(scalar.value) + } +} diff --git a/apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift b/apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift new file mode 100644 index 0000000000..733b26d2df --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Completion/CompletionReservedWords.swift @@ -0,0 +1,125 @@ +import Foundation +import TableProModels + +// Each engine's own reserved words, from its reference. `SQLKeywords` is a completion vocabulary and misses some, +// such as PostgreSQL's `user`, which a bare name would then read as. +nonisolated enum CompletionReservedWords { + static func contains(_ name: String, for type: DatabaseType) -> Bool { + words(for: type).contains(name.lowercased()) + } + + private static func words(for type: DatabaseType) -> Set { + if SQLBuilder.speaksMySQLDialect(type) { return mySQL } + switch type { + case .postgresql, .redshift: return postgreSQL + case .duckdb: return duckDB + case .mssql: return sqlServer + case .oracle: return oracle + case .sqlite: return sqlite + default: return all + } + } + + private static let postgreSQL: Set = [ + "all", "analyse", "analyze", "and", "any", "array", "as", "asc", "asymmetric", "both", "case", "cast", "check", + "collate", "column", "constraint", "create", "current_catalog", "current_date", "current_role", "current_time", + "current_timestamp", "current_user", "default", "deferrable", "desc", "distinct", "do", "else", "end", "except", + "false", "fetch", "for", "foreign", "from", "grant", "group", "having", "in", "initially", "intersect", "into", + "lateral", "leading", "limit", "localtime", "localtimestamp", "not", "null", "offset", "on", "only", "or", + "order", "placing", "primary", "references", "returning", "select", "session_user", "some", "symmetric", + "system_user", "table", "then", "to", "trailing", "true", "union", "unique", "user", "using", "variadic", "when", + "where", "window", "with", "authorization", "binary", "collation", "concurrently", "cross", "current_schema", + "freeze", "full", "ilike", "inner", "is", "isnull", "join", "left", "like", "natural", "notnull", "outer", + "overlaps", "right", "similar", "tablesample", "verbose", + ] + + private static let duckDB: Set = postgreSQL.union([ + "describe", "pivot", "pivot_longer", "pivot_wider", "qualify", "show", "summarize", "unpivot", + ]) + + private static let mySQL: Set = [ + "accessible", "add", "all", "alter", "analyze", "and", "as", "asc", "asensitive", "before", "between", "bigint", + "binary", "blob", "both", "by", "call", "cascade", "case", "change", "char", "character", "check", "collate", + "column", "condition", "constraint", "continue", "convert", "create", "cross", "cube", "cume_dist", + "current_date", "current_role", "current_time", "current_timestamp", "current_user", "cursor", "database", + "databases", "day_hour", "day_microsecond", "day_minute", "day_second", "dec", "decimal", "declare", "default", + "delayed", "delete", "dense_rank", "desc", "describe", "deterministic", "distinct", "distinctrow", "div", + "double", "drop", "dual", "each", "else", "elseif", "empty", "enclosed", "escaped", "except", "exists", "exit", + "explain", "false", "fetch", "first_value", "float", "float4", "float8", "for", "force", "foreign", "from", + "fulltext", "function", "general", "generated", "get", "grant", "group", "grouping", "groups", "having", + "high_priority", "hour_microsecond", "hour_minute", "hour_second", "if", "ignore", "in", "index", "infile", + "inner", "inout", "insensitive", "insert", "int", "int1", "int2", "int3", "int4", "int8", "integer", + "intersect", "interval", "into", "io_after_gtids", "io_before_gtids", "is", "iterate", "join", "json_table", + "key", "keys", "kill", "lag", "last_value", "lateral", "lead", "leading", "leave", "left", "like", "limit", + "linear", "lines", "load", "localtime", "localtimestamp", "lock", "long", "longblob", "longtext", "loop", + "low_priority", "manual", "master_bind", "master_ssl_verify_server_cert", "match", "maxvalue", "mediumblob", + "mediumint", "mediumtext", "middleint", "minute_microsecond", "minute_second", "mod", "modifies", "natural", + "not", "no_write_to_binlog", "nth_value", "ntile", "null", "numeric", "of", "offset", "on", "optimize", + "optimizer_costs", "option", "optionally", "or", "order", "out", "outer", "outfile", "over", "parallel", + "partition", "percent_rank", "precision", "primary", "procedure", "purge", "qualify", "range", "rank", "read", + "reads", "read_write", "real", "recursive", "references", "regexp", "release", "rename", "repeat", "replace", + "require", "resignal", "restrict", "return", "returning", "revoke", "right", "rlike", "row", "rows", + "row_number", "schema", "schemas", "second_microsecond", "select", "sensitive", "separator", "set", "show", + "signal", "slow", "smallint", "spatial", "specific", "sql", "sqlexception", "sqlstate", "sqlwarning", + "sql_big_result", "sql_calc_found_rows", "sql_small_result", "ssl", "starting", "stored", "straight_join", + "system", "table", "terminated", "then", "tinyblob", "tinyint", "tinytext", "to", "trailing", "trigger", "true", + "undo", "union", "unique", "unlock", "unsigned", "update", "usage", "use", "using", "utc_date", "utc_time", + "utc_timestamp", "values", "varbinary", "varchar", "varcharacter", "varying", "virtual", "when", "where", + "while", "window", "with", "write", "xor", "year_month", "zerofill", + ] + + private static let sqlServer: Set = [ + "add", "all", "alter", "and", "any", "as", "asc", "authorization", "backup", "begin", "between", "break", + "browse", "bulk", "by", "cascade", "case", "check", "checkpoint", "close", "clustered", "coalesce", "collate", + "column", "commit", "compute", "constraint", "contains", "containstable", "continue", "convert", "create", + "cross", "current", "current_date", "current_time", "current_timestamp", "current_user", "cursor", "database", + "dbcc", "deallocate", "declare", "default", "delete", "deny", "desc", "disk", "distinct", "distributed", + "double", "drop", "dump", "else", "end", "errlvl", "escape", "except", "exec", "execute", "exists", "exit", + "external", "fetch", "file", "fillfactor", "for", "foreign", "freetext", "freetexttable", "from", "full", + "function", "goto", "grant", "group", "having", "holdlock", "identity", "identity_insert", "identitycol", "if", + "in", "index", "inner", "insert", "intersect", "into", "is", "join", "key", "kill", "left", "like", "lineno", + "load", "merge", "national", "nocheck", "nonclustered", "not", "null", "nullif", "of", "off", "offsets", "on", + "open", "opendatasource", "openquery", "openrowset", "openxml", "option", "or", "order", "outer", "over", + "percent", "pivot", "plan", "precision", "primary", "print", "proc", "procedure", "public", "raiserror", "read", + "readtext", "reconfigure", "references", "replication", "restore", "restrict", "return", "revert", "revoke", + "right", "rollback", "rowcount", "rowguidcol", "rule", "save", "schema", "securityaudit", "select", + "semantickeyphrasetable", "semanticsimilaritydetailstable", "semanticsimilaritytable", "session_user", "set", + "setuser", "shutdown", "some", "statistics", "system_user", "table", "tablesample", "textsize", "then", "to", + "top", "tran", "transaction", "trigger", "truncate", "try_convert", "tsequal", "union", "unique", "unpivot", + "update", "updatetext", "use", "user", "values", "varying", "view", "waitfor", "when", "where", "while", "with", + "writetext", + ] + + private static let oracle: Set = [ + "access", "add", "all", "alter", "and", "any", "as", "asc", "audit", "between", "by", "char", "check", + "cluster", "column", "comment", "compress", "connect", "create", "current", "date", "decimal", "default", + "delete", "desc", "distinct", "drop", "else", "exclusive", "exists", "file", "float", "for", "from", "grant", + "group", "having", "identified", "immediate", "in", "increment", "index", "initial", "insert", "integer", + "intersect", "into", "is", "level", "like", "lock", "long", "maxextents", "minus", "mlslabel", "mode", + "modify", "noaudit", "nocompress", "not", "nowait", "null", "number", "of", "offline", "on", "online", "option", + "or", "order", "pctfree", "prior", "public", "raw", "rename", "resource", "revoke", "row", "rowid", "rownum", + "rows", "select", "session", "set", "share", "size", "smallint", "start", "successful", "synonym", "sysdate", + "table", "then", "to", "trigger", "uid", "union", "unique", "update", "user", "validate", "values", "varchar", + "varchar2", "view", "whenever", "where", "with", + ] + + private static let sqlite: Set = [ + "abort", "action", "add", "after", "all", "alter", "always", "analyze", "and", "as", "asc", "attach", + "autoincrement", "before", "begin", "between", "by", "cascade", "case", "cast", "check", "collate", "column", + "commit", "conflict", "constraint", "create", "cross", "current", "current_date", "current_time", + "current_timestamp", "database", "default", "deferrable", "deferred", "delete", "desc", "detach", "distinct", + "do", "drop", "each", "else", "end", "escape", "except", "exclude", "exclusive", "exists", "explain", "fail", + "filter", "first", "following", "for", "foreign", "from", "full", "generated", "glob", "group", "groups", + "having", "if", "ignore", "immediate", "in", "index", "indexed", "initially", "inner", "insert", "instead", + "intersect", "into", "is", "isnull", "join", "key", "last", "left", "like", "limit", "match", "materialized", + "natural", "no", "not", "nothing", "notnull", "null", "nulls", "of", "offset", "on", "or", "order", "others", + "outer", "over", "partition", "plan", "pragma", "preceding", "primary", "query", "raise", "range", "recursive", + "references", "regexp", "reindex", "release", "rename", "replace", "restrict", "returning", "right", + "rollback", "row", "rows", "savepoint", "select", "set", "table", "temp", "temporary", "then", "ties", "to", + "transaction", "trigger", "unbounded", "union", "unique", "update", "using", "vacuum", "values", "view", + "virtual", "when", "where", "window", "with", "without", + ] + + private static let all: Set = postgreSQL.union(duckDB).union(mySQL).union(sqlServer).union(oracle) + .union(sqlite) +} diff --git a/apple/TableProMobile/TableProMobile/Completion/QueryCompletionCatalog.swift b/apple/TableProMobile/TableProMobile/Completion/QueryCompletionCatalog.swift new file mode 100644 index 0000000000..7d0dc43fb0 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Completion/QueryCompletionCatalog.swift @@ -0,0 +1,232 @@ +import Foundation +import os +import TableProAutocomplete +import TableProModels + +// Nil when there is no session to read from: nothing is read and nothing is kept. +typealias QueryColumnReader = @Sendable (_ table: String, _ schema: String?) async throws -> [ColumnInfo]? + +actor QueryCompletionCatalog: SQLCompletionCatalog { + private static let logger = Logger(subsystem: "com.TablePro", category: "QueryCompletionCatalog") + + nonisolated private struct ColumnKey: Hashable, Sendable { + let table: String + let schema: String? + } + + nonisolated private struct CachedColumns: Sendable { + let generation: Int + let columns: [ColumnInfo] + } + + private var tables: [TableInfo] + private let databaseType: DatabaseType + private let schema: String? + private let readColumns: QueryColumnReader + private var columnReadsAllowed = true + private var columnGeneration = 0 + private var columns: [ColumnKey: CachedColumns] = [:] + private var reads: [ColumnKey: Task<[ColumnInfo]?, any Error>] = [:] + + init(tables: [TableInfo], databaseType: DatabaseType, schema: String?, readColumns: @escaping QueryColumnReader) { + self.tables = tables + self.databaseType = databaseType + self.schema = schema + self.readColumns = readColumns + } + + func replaceTables(_ tables: [TableInfo]) { + self.tables = tables + } + + func setColumnReadsAllowed(_ allowed: Bool) { + columnReadsAllowed = allowed + } + + // Each cached table is read again on its next use and keeps its columns when that read fails. + func markColumnsStale() { + columnGeneration += 1 + } + + func finishReads() async { + for read in Array(reads.values) { + _ = try? await read.value + } + } + + func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? { + if let reference = references.first(where: { reference in + reference.alias.map { Self.matches($0, aliasOrName) } ?? false + }) { + return listedTable(named: reference.tableName, schema: reference.schema)?.name ?? reference.tableName + } + if let reference = references.first(where: { Self.matches($0.tableName, aliasOrName) }) { + return listedTable(named: reference.tableName, schema: reference.schema)?.name ?? reference.tableName + } + return listedTable(named: aliasOrName, schema: nil)?.name + } + + func columnCompletionItems(for tableName: String, schema: String?) async -> [SQLCompletionItem] { + await columns(of: tableName, schema: schema).map { column in + columnItem(label: column.name, insertText: quoted(column.name), dataType: column.typeName) + } + } + + func tableCompletionItems() -> [SQLCompletionItem] { + tables.map { table in + let isView = table.type == .view || table.type == .materializedView + return SQLCompletionItem(label: table.name, kind: isView ? .view : .table, insertText: quoted(table.name)) + } + } + + func tableCompletionItems(inSchema schema: String) -> [SQLCompletionItem] { + isKnownSchema(schema) ? tableCompletionItems() : [] + } + + func schemaCompletionItems() -> [SQLCompletionItem] { + [] + } + + func namespaceCompletionItems() -> [SQLCompletionItem] { + [] + } + + func isKnownSchema(_ name: String) -> Bool { + schema.map { Self.matches($0, name) } ?? false + } + + func isKnownDatabase(_ name: String) -> Bool { + false + } + + func allowedValues(forColumn column: String, in references: [TableReference]) -> [String] { + [] + } + + func allColumnsInScope(for references: [TableReference]) async -> [SQLCompletionItem] { + let isQualified = references.count > 1 + // Two tables of one name from different schemas are told apart by their schema. + let unaliasedNames = references.filter { $0.alias == nil }.map { $0.tableName.lowercased() } + var items: [SQLCompletionItem] = [] + for reference in references { + var label = reference.identifier + var prefix = qualifier(for: reference) + if reference.alias == nil, let schema = reference.schema, + unaliasedNames.filter({ $0 == reference.tableName.lowercased() }).count > 1 { + label = "\(schema).\(label)" + prefix = "\(asWritten(schema)).\(prefix)" + } + let entries: [(name: String, insertText: String, dataType: String?)] + if let derived = reference.derivedColumns { + entries = derived.map { ($0, asWritten($0), nil) } + } else { + entries = await columns(of: reference.tableName, schema: reference.schema).map { + ($0.name, quoted($0.name), $0.typeName) + } + } + for entry in entries { + items.append(columnItem( + label: isQualified ? "\(label).\(entry.name)" : entry.name, + insertText: isQualified ? "\(prefix).\(entry.insertText)" : entry.insertText, + dataType: entry.dataType + )) + } + } + return items + } + + func allColumnsFromCachedTables() -> [SQLCompletionItem] { + // Sorted, because the ranker keeps emission order between equal scores and a dictionary's order is per process. + let cached = columns.sorted { lhs, rhs in + (lhs.key.schema ?? "", lhs.key.table) < (rhs.key.schema ?? "", rhs.key.table) + } + var nameCounts: [String: Int] = [:] + for entry in cached { + for column in entry.value.columns { + nameCounts[column.name.lowercased(), default: 0] += 1 + } + } + return cached.flatMap { entry in + entry.value.columns.map { column in + let isAmbiguous = (nameCounts[column.name.lowercased()] ?? 0) > 1 + let prefix = tableQualifier(entry.key.table, schema: entry.key.schema) + var item = columnItem( + label: isAmbiguous ? "\(entry.key.table).\(column.name)" : column.name, + insertText: isAmbiguous ? "\(prefix).\(quoted(column.name))" : quoted(column.name), + dataType: column.typeName + ) + item.sortPriority = 150 + return item + } + } + } + + private func columns(of tableName: String, schema: String?) async -> [ColumnInfo] { + let key = columnKey(tableName, schema: schema) + let known = columns[key] + if let known, known.generation == columnGeneration { return known.columns } + if let pending = reads[key] { + return (try? await pending.value) ?? known?.columns ?? [] + } + guard columnReadsAllowed else { return known?.columns ?? [] } + + let generation = columnGeneration + let readColumns = self.readColumns + let read = Task<[ColumnInfo]?, any Error> { + try await readColumns(key.table, key.schema) + } + reads[key] = read + let answer: [ColumnInfo]? + do { + answer = try await read.value + } catch { + Self.logger.warning("Reading columns for completion failed: \(error.localizedDescription, privacy: .private)") + answer = nil + } + if reads[key] == read { reads[key] = nil } + if let answer { columns[key] = CachedColumns(generation: generation, columns: answer) } + return answer ?? columns[key]?.columns ?? [] + } + + private func columnKey(_ tableName: String, schema: String?) -> ColumnKey { + let readSchema = isActiveSchema(schema) ? nil : schema + return ColumnKey(table: listedTable(named: tableName, schema: schema)?.name ?? tableName, schema: readSchema) + } + + private func qualifier(for reference: TableReference) -> String { + if reference.isIdentifierQuoted { return SQLBuilder.quoteIdentifier(reference.identifier, for: databaseType) } + if let alias = reference.alias { return asWritten(alias) } + return tableQualifier(reference.tableName, schema: reference.schema) + } + + private func tableQualifier(_ tableName: String, schema: String?) -> String { + if let listed = listedTable(named: tableName, schema: schema) { return quoted(listed.name) } + return asWritten(tableName) + } + + private func listedTable(named name: String, schema: String?) -> TableInfo? { + guard isActiveSchema(schema) else { return nil } + return tables.first { $0.name == name } ?? tables.first { Self.matches($0.name, name) } + } + + private func isActiveSchema(_ schema: String?) -> Bool { + guard let schema else { return true } + return isKnownSchema(schema) + } + + private func columnItem(label: String, insertText: String, dataType: String?) -> SQLCompletionItem { + SQLCompletionItem(label: label, kind: .column, insertText: insertText, detail: dataType) + } + + private func quoted(_ name: String) -> String { + CompletionIdentifierQuoting.quoted(name, for: databaseType) + } + + private func asWritten(_ name: String) -> String { + CompletionIdentifierQuoting.quotedAsWritten(name, for: databaseType) + } + + private static func matches(_ lhs: String, _ rhs: String) -> Bool { + lhs.caseInsensitiveCompare(rhs) == .orderedSame + } +} diff --git a/apple/TableProMobile/TableProMobile/Completion/QueryCompletionController.swift b/apple/TableProMobile/TableProMobile/Completion/QueryCompletionController.swift new file mode 100644 index 0000000000..f5addde48a --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Completion/QueryCompletionController.swift @@ -0,0 +1,350 @@ +import Foundation +import TableProAutocomplete +import TableProDatabase +import TableProModels +import TableProSQLGrammar + +nonisolated struct QueryCompletionScope: Equatable, Sendable { + let connectionId: UUID + // The driver's identity: a reconnect brings a new driver under the same connection, database and schema. + let sessionID: ObjectIdentifier? + let database: String + let schema: String + let databaseType: DatabaseType + let tables: [TableInfo] + let isRunning: Bool +} + +nonisolated struct QuerySuggestion: Identifiable, Equatable, Sendable { + let id: Int + let label: String + let kind: SQLCompletionKind +} + +nonisolated struct QueryCompletionEdit: Equatable, Sendable { + let range: NSRange + let text: String + let caret: Int +} + +@MainActor +final class QueryCompletionController { + var onSuggestionsChange: (([QuerySuggestion]) -> Void)? + private(set) var suggestions: [QuerySuggestion] = [] + + private static let suggestionLimit = 8 + + private struct Input { + let text: String + let selection: NSRange + let hasMarkedText: Bool + } + + private struct Position: Equatable { + let text: String + let selection: NSRange + } + + private struct Engine { + let catalog: QueryCompletionCatalog + let provider: SQLCompletionProvider + let databaseType: DatabaseType + } + + private struct Answer { + let position: Position + let items: [SQLCompletionItem] + let range: NSRange + let quotedName: SQLQuotedName? + let databaseType: DatabaseType + } + + private var scope: QueryCompletionScope? + private var engine: Engine? + private var catalogWork: Task? + private var latest: Input? + private var pending: Input? + private var scheduled: Task? + private var running: [Int: Task] = [:] + private var generation = 0 + private var answer: Answer? + private var hiddenAt: Position? + + func update(scope newScope: QueryCompletionScope, readColumns: @escaping QueryColumnReader) { + let previous = scope + guard previous != newScope else { return } + scope = newScope + + guard newScope.databaseType.speaksSQL else { + engine = nil + catalogWork = nil + generation += 1 + show([], answer: nil) + return + } + + if let previous, let engine, previous.connectionId == newScope.connectionId, + previous.sessionID == newScope.sessionID, + previous.database == newScope.database, previous.schema == newScope.schema, + previous.databaseType == newScope.databaseType { + let catalog = engine.catalog + let tablesChanged = previous.tables != newScope.tables + if tablesChanged { + let tables = newScope.tables + enqueue { await catalog.replaceTables(tables) } + } + if previous.isRunning, !newScope.isRunning { + // The run may have altered a table. + enqueue { await catalog.markColumnsStale() } + } + if previous.isRunning != newScope.isRunning { + let allowed = !newScope.isRunning + enqueue { await catalog.setColumnReadsAllowed(allowed) } + } + // The run's end asks nothing itself: a column read then would replace the row count and warnings the run + // left on the session, so the next keystroke reads instead. + if tablesChanged { + requestAgain() + } + return + } + + let catalog = QueryCompletionCatalog( + tables: newScope.tables, + databaseType: newScope.databaseType, + schema: newScope.schema.isEmpty ? nil : newScope.schema, + readColumns: readColumns + ) + let grammar = SQLLexicalReadings.resolve( + databaseTypeId: newScope.databaseType.rawValue, declared: nil, session: nil + ).execution + engine = Engine( + catalog: catalog, + provider: SQLCompletionProvider(catalog: catalog, grammar: grammar, vocabulary: .generic), + databaseType: newScope.databaseType + ) + catalogWork = nil + if newScope.isRunning { + enqueue { await catalog.setColumnReadsAllowed(false) } + } + generation += 1 + show([], answer: nil) + requestAgain() + } + + // A keystroke reaches both text view delegate methods, so requests in one main-queue turn run once, on the newest. + func request(text: String, selection: NSRange, hasMarkedText: Bool) { + let input = Input(text: text, selection: selection, hasMarkedText: hasMarkedText) + latest = input + pending = input + guard scheduled == nil else { return } + scheduled = Task { [weak self] in + self?.startPending() + } + } + + func settle() async { + while true { + if let scheduled { + await scheduled.value + } else if let entry = running.first { + await entry.value.value + running[entry.key] = nil + } else { + await catalogWork?.value + return + } + } + } + + func edit(for suggestion: QuerySuggestion, in text: String, selection: NSRange) -> QueryCompletionEdit? { + guard let answer, answer.position == Position(text: text, selection: selection), + answer.items.indices.contains(suggestion.id) else { return nil } + let item = answer.items[suggestion.id] + guard item.label == suggestion.label, item.kind == suggestion.kind else { return nil } + + let source = text as NSString + generation += 1 + switch item.kind { + case .table, .view, .column, .schema, .alias: + let edit = Self.nameEdit(for: item, in: answer, text: source) + // The exact match would lead again and Tab would insert it twice, so the bar waits for the next edit. + hiddenAt = Position( + text: source.replacingCharacters(in: edit.range, with: edit.text), + selection: NSRange(location: edit.caret, length: 0) + ) + show([], answer: nil) + return edit + case .keyword, .operator: + return Self.keywordEdit(for: item, replacing: answer.range, in: source) + case .function, .favorite: + let insertion = SQLCompletionInsertion.resolve(for: item) + return QueryCompletionEdit( + range: answer.range, + text: insertion.text, + caret: answer.range.location + insertion.cursorOffset + ) + } + } + + // A read already sent can issue its next statement mid-run, which ends a PostgreSQL stream, so a run waits for it. + func finishColumnReads() async { + await engine?.catalog.finishReads() + } + + func hide() { + hiddenAt = latest.map { Position(text: $0.text, selection: $0.selection) } + generation += 1 + show([], answer: nil) + } + + static func columnReader(session: @escaping @MainActor () -> ConnectionSession?) -> QueryColumnReader { + { table, schema in + guard let driver = await session()?.driver else { return nil } + return try await driver.fetchColumns(table: table, schema: schema) + } + } + + private func requestAgain() { + guard let latest else { return } + request(text: latest.text, selection: latest.selection, hasMarkedText: latest.hasMarkedText) + } + + private func enqueue(_ work: @escaping @Sendable () async -> Void) { + let previous = catalogWork + catalogWork = Task { + await previous?.value + await work() + } + } + + private func startPending() { + scheduled = nil + guard let input = pending else { return } + pending = nil + generation += 1 + let ticket = generation + running[ticket] = Task { [weak self] in + await self?.compute(input, ticket: ticket) + } + } + + private func compute(_ input: Input, ticket: Int) async { + defer { running[ticket] = nil } + guard ticket == generation else { return } + + let position = Position(text: input.text, selection: input.selection) + if hiddenAt != position { hiddenAt = nil } + guard let engine, hiddenAt == nil, !input.hasMarkedText, input.selection.length == 0 else { + show([], answer: nil) + return + } + + let text = input.text as NSString + let caret = min(max(input.selection.location, 0), text.length) + guard !SQLCompletionTriggerPolicy.suppressesAfterStatementBreak(in: text, at: caret) else { + show([], answer: nil) + return + } + + let catalogWork = self.catalogWork + await catalogWork?.value + guard ticket == generation else { return } + + let window = SQLCompletionWindow.cut(text, around: caret) + let session = await engine.provider.completionSession( + text: window.text, + cursorPosition: caret - window.start, + forcedTableReferences: nil + ) + guard ticket == generation else { return } + + let context = session.context + // iOS has no manual trigger and the bar never covers the text, so it fills at every code position. + guard !SQLCompletionTriggerPolicy.suppressesEmptyPrefix(context, isManualTrigger: true) else { + show([], answer: nil) + return + } + let items = SQLCompletionCasing.applied( + to: Array(session.items.prefix(Self.suggestionLimit)), + typedPrefix: context.prefix, + policy: .default + ) + let range = NSRange(location: context.prefixRange.lowerBound + window.start, length: context.prefixRange.count) + let quotedName = context.quotedName.map { name in + SQLQuotedName(open: name.open + window.start, close: name.close.map { $0 + window.start }) + } + show(items, answer: Answer( + position: position, + items: items, + range: range, + quotedName: quotedName, + databaseType: engine.databaseType + )) + } + + private func show(_ items: [SQLCompletionItem], answer: Answer?) { + self.answer = items.isEmpty ? nil : answer + let list = items.enumerated().map { index, item in + QuerySuggestion(id: index, label: item.label, kind: item.kind) + } + guard list != suggestions else { return } + suggestions = list + onSuggestionsChange?(list) + } + + private static func nameEdit(for item: SQLCompletionItem, in answer: Answer, text: NSString) -> QueryCompletionEdit { + let insertion = SQLCompletionInsertion.resolve(for: item) + guard let quoted = answer.quotedName else { + return QueryCompletionEdit( + range: answer.range, + text: insertion.text, + caret: answer.range.location + insertion.cursorOffset + ) + } + if let close = quoted.close { + if isQualifiedColumn(item, for: answer.databaseType) { + let token = NSRange(location: quoted.open, length: close + 1 - quoted.open) + return QueryCompletionEdit( + range: token, + text: insertion.text, + caret: token.location + insertion.cursorOffset + ) + } + let closeText = text.substring(with: NSRange(location: close, length: 1)) + let name = item.label.replacingOccurrences(of: closeText, with: closeText + closeText) + let inner = NSRange(location: quoted.open + 1, length: close - quoted.open - 1) + return QueryCompletionEdit(range: inner, text: name, caret: inner.location + (name as NSString).length + 1) + } + // An opening quote with no close of its own is overwritten by the quoted name, so it is never left doubled. + let typed = NSRange(location: quoted.open, length: NSMaxRange(answer.range) - quoted.open) + return QueryCompletionEdit(range: typed, text: insertion.text, caret: typed.location + insertion.cursorOffset) + } + + // `o.total` is two names, so it cannot go inside the quotes typed around one. + private static func isQualifiedColumn(_ item: SQLCompletionItem, for type: DatabaseType) -> Bool { + item.kind == .column && item.label.contains(".") + && item.insertText != CompletionIdentifierQuoting.quoted(item.label, for: type) + } + + private static func keywordEdit( + for item: SQLCompletionItem, + replacing range: NSRange, + in text: NSString + ) -> QueryCompletionEdit { + let word = SQLCompletionInsertion.resolve(for: item).text + let length = (word as NSString).length + let end = NSMaxRange(range) + guard length > 1 else { + return QueryCompletionEdit(range: range, text: word, caret: range.location + length) + } + if end < text.length, isWhitespace(text.character(at: end)) { + return QueryCompletionEdit(range: range, text: word, caret: range.location + length + 1) + } + return QueryCompletionEdit(range: range, text: word + " ", caret: range.location + length + 1) + } + + private static func isWhitespace(_ unit: unichar) -> Bool { + unit == 0x20 || unit == 0x09 || unit == 0x0A || unit == 0x0D + } +} diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index ca1c8643d8..b364708270 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -1984,6 +1984,10 @@ } } }, + "All columns" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "All data in \"%@\" will be permanently deleted." : { "localizations" : { "ko" : { @@ -5008,6 +5012,10 @@ } } }, + "Count all rows" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Create" : { "localizations" : { "ko" : { @@ -10223,6 +10231,10 @@ } } }, + "lowercase" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Mac socket %@" : { }, @@ -10407,6 +10419,14 @@ } } }, + "Match what I type, otherwise lowercase" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Match what I type, otherwise UPPERCASE" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Materialized View" : { }, @@ -21266,6 +21286,10 @@ }, "Upload Again" : { + }, + "UPPERCASE" : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Uptrend" : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/Views/Components/QuerySuggestionBar.swift b/apple/TableProMobile/TableProMobile/Views/Components/QuerySuggestionBar.swift new file mode 100644 index 0000000000..3e6b56d507 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/QuerySuggestionBar.swift @@ -0,0 +1,108 @@ +import TableProAutocomplete +import UIKit + +final class QuerySuggestionBar: UIView { + var onSelect: ((QuerySuggestion) -> Void)? + private(set) var suggestions: [QuerySuggestion] = [] + + private let chips = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + chips.axis = .horizontal + chips.spacing = 8 + + let row = UIStackView(arrangedSubviews: [chips, Self.makeDivider()]) + row.axis = .horizontal + row.spacing = 8 + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: topAnchor), + row.leadingAnchor.constraint(equalTo: leadingAnchor), + row.trailingAnchor.constraint(equalTo: trailingAnchor), + row.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("QuerySuggestionBar does not support NSCoder init") + } + + func show(_ suggestions: [QuerySuggestion]) { + guard suggestions != self.suggestions else { return } + self.suggestions = suggestions + for chip in chips.arrangedSubviews { + chip.removeFromSuperview() + } + for (index, suggestion) in suggestions.enumerated() { + chips.addArrangedSubview(makeChip(for: suggestion, isTabTarget: index == 0)) + } + isHidden = suggestions.isEmpty + } + + static func keyConfiguration(title: String, isProminent: Bool = false) -> UIButton.Configuration { + var config: UIButton.Configuration = isProminent ? .filled() : .gray() + config.title = title + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var attrs = incoming + attrs.font = UIFont.monospacedSystemFont(ofSize: 14, weight: .medium) + return attrs + } + // A title that can wrap makes UIStackView give every key in a row the width of the widest. + config.titleLineBreakMode = .byTruncatingTail + config.cornerStyle = .capsule + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) + return config + } + + private func makeChip(for suggestion: QuerySuggestion, isTabTarget: Bool) -> UIButton { + let button = UIButton( + configuration: Self.keyConfiguration(title: suggestion.label, isProminent: isTabTarget), + primaryAction: UIAction { [weak self] _ in self?.onSelect?(suggestion) } + ) + button.accessibilityLabel = suggestion.label + button.accessibilityValue = Self.kindName(suggestion.kind) + return button + } + + private static func makeDivider() -> UIView { + let container = UIView() + let line = UIView() + line.backgroundColor = .separator + line.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(line) + + let width = line.widthAnchor.constraint( + equalToConstant: HairlineThickness.points(forDisplayScale: container.traitCollection.displayScale) + ) + container.registerForTraitChanges([UITraitDisplayScale.self]) { (view: UIView, _) in + width.constant = HairlineThickness.points(forDisplayScale: view.traitCollection.displayScale) + } + + NSLayoutConstraint.activate([ + width, + line.heightAnchor.constraint(equalTo: container.heightAnchor, multiplier: 0.5), + line.centerYAnchor.constraint(equalTo: container.centerYAnchor), + line.leadingAnchor.constraint(equalTo: container.leadingAnchor), + line.trailingAnchor.constraint(equalTo: container.trailingAnchor) + ]) + return container + } + + private static func kindName(_ kind: SQLCompletionKind) -> String { + switch kind { + case .keyword: String(localized: "Keyword") + case .table: String(localized: "Table") + case .view: String(localized: "View") + case .column: String(localized: "Column") + case .function: String(localized: "Function") + case .schema: String(localized: "Schema") + case .alias: String(localized: "Alias") + case .operator: String(localized: "Operator") + case .favorite: String(localized: "Favorite") + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SQLEditorTextView.swift b/apple/TableProMobile/TableProMobile/Views/Components/SQLEditorTextView.swift new file mode 100644 index 0000000000..5d47da9970 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/SQLEditorTextView.swift @@ -0,0 +1,46 @@ +import UIKit + +final class SQLEditorTextView: UITextView { + var showsSuggestions = false + var onAcceptSuggestion: (() -> Void)? + var onHideSuggestions: (() -> Void)? + + // Return, the arrows and Control keep their editing meaning (HIG Keyboards); Full Keyboard Access reaches the other chips. + private lazy var suggestionCommands: [UIKeyCommand] = { + let accept = UIKeyCommand( + title: String(localized: "Accept Suggestion"), + action: #selector(acceptSuggestion(_:)), + input: "\t" + ) + let hide = UIKeyCommand( + title: String(localized: "Hide Suggestions"), + action: #selector(hideSuggestions(_:)), + input: UIKeyCommand.inputEscape + ) + // Without priority the text system takes the key first and Tab types a tab character. + for command in [accept, hide] { + command.wantsPriorityOverSystemBehavior = true + } + return [accept, hide] + }() + + override var keyCommands: [UIKeyCommand]? { + guard showsSuggestions else { return super.keyCommands } + return (super.keyCommands ?? []) + suggestionCommands + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(acceptSuggestion(_:)) || action == #selector(hideSuggestions(_:)) { + return showsSuggestions + } + return super.canPerformAction(action, withSender: sender) + } + + @objc private func acceptSuggestion(_ sender: UIKeyCommand) { + onAcceptSuggestion?() + } + + @objc private func hideSuggestions(_ sender: UIKeyCommand) { + onHideSuggestions?() + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift b/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift index 7987c2dec8..6731f3b638 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift @@ -4,16 +4,18 @@ import UIKit struct SQLHighlightTextView: UIViewRepresentable { @Binding var text: String @Binding var isFocused: Bool + let completion: QueryCompletionController? private static let font = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular) - init(text: Binding, isFocused: Binding = .constant(false)) { + init(text: Binding, isFocused: Binding = .constant(false), completion: QueryCompletionController? = nil) { self._text = text self._isFocused = isFocused + self.completion = completion } func makeUIView(context: Context) -> UITextView { - let textView = UITextView() + let textView = SQLEditorTextView() context.coordinator.textView = textView textView.delegate = context.coordinator textView.font = Self.font @@ -23,11 +25,13 @@ struct SQLHighlightTextView: UIViewRepresentable { textView.smartDashesType = .no textView.smartInsertDeleteType = .no textView.keyboardType = .asciiCapable + textView.inlinePredictionType = .no textView.textColor = .label textView.backgroundColor = .clear textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4) textView.textStorage.delegate = context.coordinator textView.inputAccessoryView = context.coordinator.makeAccessoryToolbar() + context.coordinator.connectCompletion(to: textView) return textView } @@ -40,6 +44,7 @@ struct SQLHighlightTextView: UIViewRepresentable { SQLSyntaxHighlighter.highlight(textView.textStorage, in: NSRange(location: 0, length: length)) } context.coordinator.isUpdating = false + context.coordinator.requestSuggestions(for: textView) } if isFocused, !textView.isFirstResponder { textView.becomeFirstResponder() @@ -54,6 +59,8 @@ struct SQLHighlightTextView: UIViewRepresentable { var parent: SQLHighlightTextView var isUpdating = false weak var textView: UITextView? + private weak var suggestionBar: QuerySuggestionBar? + private weak var accessoryScrollView: UIScrollView? init(_ parent: SQLHighlightTextView) { self.parent = parent @@ -62,6 +69,11 @@ struct SQLHighlightTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { guard !isUpdating else { return } parent.text = textView.text + requestSuggestions(for: textView) + } + + func textViewDidChangeSelection(_ textView: UITextView) { + requestSuggestions(for: textView) } func textViewDidBeginEditing(_ textView: UITextView) { @@ -112,6 +124,12 @@ struct SQLHighlightTextView: UIViewRepresentable { stackView.spacing = 8 stackView.translatesAutoresizingMaskIntoConstraints = false + let suggestionBar = QuerySuggestionBar() + suggestionBar.onSelect = { [weak self] suggestion in self?.accept(suggestion) } + stackView.addArrangedSubview(suggestionBar) + self.suggestionBar = suggestionBar + accessoryScrollView = scrollView + let keywords = ["SELECT", "FROM", "WHERE", "JOIN", "AND", "OR", "INSERT", "UPDATE", "DELETE", "*", "(", ")", ";"] for keyword in keywords { stackView.addArrangedSubview(makeKeywordButton(keyword)) @@ -155,16 +173,7 @@ struct SQLHighlightTextView: UIViewRepresentable { } private func makeKeywordButton(_ keyword: String) -> UIButton { - var config = UIButton.Configuration.gray() - config.title = keyword - config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in - var attrs = incoming - attrs.font = UIFont.monospacedSystemFont(ofSize: 14, weight: .medium) - return attrs - } - config.cornerStyle = .capsule - config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) - let button = UIButton(configuration: config) + let button = UIButton(configuration: QuerySuggestionBar.keyConfiguration(title: keyword)) button.addTarget(self, action: #selector(keywordTapped(_:)), for: .touchUpInside) return button } @@ -178,5 +187,61 @@ struct SQLHighlightTextView: UIViewRepresentable { @objc private func dismissKeyboard() { textView?.resignFirstResponder() } + + func connectCompletion(to textView: SQLEditorTextView) { + guard let completion = parent.completion else { return } + completion.onSuggestionsChange = { [weak self] suggestions in self?.showSuggestions(suggestions) } + textView.onAcceptSuggestion = { [weak self] in + guard let first = self?.parent.completion?.suggestions.first else { return } + self?.accept(first) + } + textView.onHideSuggestions = { [weak self] in + guard let completion = self?.parent.completion else { return } + completion.hide() + self?.showSuggestions(completion.suggestions) + } + } + + func requestSuggestions(for textView: UITextView) { + parent.completion?.request( + text: textView.text, + selection: textView.selectedRange, + hasMarkedText: textView.markedTextRange != nil + ) + } + + func accept(_ suggestion: QuerySuggestion) { + guard let completion = parent.completion, let textView, textView.markedTextRange == nil, + let edit = completion.edit(for: suggestion, in: textView.text, selection: textView.selectedRange) + else { return } + apply(edit, to: textView) + showSuggestions(completion.suggestions) + } + + // If replace(_:withText:) skips textViewDidChange, the next updateUIView would put the old text back. + func apply(_ edit: QueryCompletionEdit, to textView: UITextView) { + guard let start = textView.position(from: textView.beginningOfDocument, offset: edit.range.location), + let end = textView.position(from: start, offset: edit.range.length), + let range = textView.textRange(from: start, to: end) else { return } + textView.replace(range, withText: edit.text) + textView.selectedRange = NSRange(location: edit.caret, length: 0) + if parent.text != textView.text { + parent.text = textView.text + } + } + + private func showSuggestions(_ suggestions: [QuerySuggestion]) { + (textView as? SQLEditorTextView)?.showsSuggestions = !suggestions.isEmpty + guard let suggestionBar, suggestionBar.suggestions != suggestions else { return } + suggestionBar.show(suggestions) + scrollAccessoryToLeadingEdge() + } + + // scrollRectToVisible would line up the trailing edge of a bar wider than the scroll view, which is zero wide + // while the keyboard is off screen. + private func scrollAccessoryToLeadingEdge() { + guard let scrollView = accessoryScrollView else { return } + scrollView.contentOffset.x = -scrollView.adjustedContentInset.left + } } } diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 28e0bf935d..893725c4c5 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -14,6 +14,7 @@ struct QueryEditorView: View { @State private var query = "" @State private var editorFocused = false @State private var viewModel = QueryEditorViewModel() + @State private var completion = QueryCompletionController() @State private var appError: AppError? @State private var isExecuting = false @State private var executionTime: TimeInterval? @@ -41,6 +42,18 @@ struct QueryEditorView: View { private var safeModeLevel: SafeModeLevel { coordinator.connection.safeModeLevel } private var connectionId: UUID { coordinator.connection.id } + private var completionScope: QueryCompletionScope { + QueryCompletionScope( + connectionId: connectionId, + sessionID: session.map { ObjectIdentifier($0.driver) }, + database: coordinator.activeDatabase, + schema: activeSchema ?? "", + databaseType: databaseType, + tables: tables, + isRunning: isExecuting + ) + } + var body: some View { VStack(spacing: 0) { editorSection @@ -79,6 +92,9 @@ struct QueryEditorView: View { .onChange(of: MemoryPressureMonitor.shared.currentLevel) { _, level in Task { await viewModel.handlePressure(level) } } + .onChange(of: completionScope, initial: true) { _, scope in + completion.update(scope: scope, readColumns: QueryCompletionController.columnReader { coordinator.session }) + } .alert(String(localized: "Write Query Blocked"), isPresented: $showWriteBlockedAlert) { Button("OK", role: .cancel) {} } message: { @@ -130,7 +146,7 @@ struct QueryEditorView: View { private var editorSection: some View { VStack(spacing: 0) { - SQLHighlightTextView(text: $query, isFocused: $editorFocused) + SQLHighlightTextView(text: $query, isFocused: $editorFocused, completion: completion) .frame(minHeight: 80, maxHeight: editorMaxHeight) actionBar @@ -523,6 +539,7 @@ struct QueryEditorView: View { executionStartTime = startedAt appError = nil + await completion.finishColumnReads() let script = await plan(trimmed, on: session) guard !script.isEmpty else { isExecuting = false diff --git a/apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift b/apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift new file mode 100644 index 0000000000..fb48d5daac --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Completion/CompletionIdentifierQuotingTests.swift @@ -0,0 +1,116 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +struct CompletionIdentifierQuotingTests { + @Test("a name the engine reads as written stays bare", arguments: [ + ("Invoice", DatabaseType.sqlite), + ("orders", .postgresql), + ("orders", .redshift), + ("OrderItems", .mysql), + ("OrderItems", .mariadb), + ("Orders", .mssql), + ("ORDERS", .oracle), + ("Orders", .duckdb), + ]) + func bareNames(name: String, type: DatabaseType) { + #expect(CompletionIdentifierQuoting.quoted(name, for: type) == name) + } + + @Test("an engine that folds unquoted names quotes a name in any other case") + func foldedCase() { + #expect(CompletionIdentifierQuoting.quoted("Orders", for: .postgresql) == "\"Orders\"") + #expect(CompletionIdentifierQuoting.quoted("InvoiceLine", for: .redshift) == "\"InvoiceLine\"") + #expect(CompletionIdentifierQuoting.quoted("orders", for: .oracle) == "\"orders\"") + #expect(CompletionIdentifierQuoting.quoted("Orders", for: .oracle) == "\"Orders\"") + #expect(CompletionIdentifierQuoting.quoted("Orders", for: .mysql) == "Orders") + #expect(CompletionIdentifierQuoting.quoted("Orders", for: .sqlite) == "Orders") + } + + @Test("a reserved word is quoted with the engine's own quote") + func reservedWord() { + #expect(CompletionIdentifierQuoting.quoted("order", for: .postgresql) == "\"order\"") + #expect(CompletionIdentifierQuoting.quoted("order", for: .mysql) == "`order`") + #expect(CompletionIdentifierQuoting.quoted("order", for: .mssql) == "[order]") + #expect(CompletionIdentifierQuoting.quoted("order", for: .sqlite) == "\"order\"") + #expect(CompletionIdentifierQuoting.quoted("ORDER", for: .oracle) == "\"ORDER\"") + } + + @Test("date reads as a keyword on every engine", arguments: [ + (DatabaseType.postgresql, "\"date\""), + (.redshift, "\"date\""), + (.mysql, "`date`"), + (.mariadb, "`date`"), + (.tidb, "`date`"), + (.mssql, "[date]"), + (.sqlite, "\"date\""), + (.duckdb, "\"date\""), + (.oracle, "\"date\""), + ]) + func dateColumn(type: DatabaseType, expected: String) { + #expect(CompletionIdentifierQuoting.quoted("date", for: type) == expected) + } + + @Test("a word the engine reserves is quoted where the completion vocabulary lacks it", arguments: [ + ("user", DatabaseType.postgresql, "\"user\""), + ("window", .redshift, "\"window\""), + ("pivot", .duckdb, "\"pivot\""), + ("dual", .mysql, "`dual`"), + ("file", .mssql, "[file]"), + ("SIZE", .oracle, "\"SIZE\""), + ("pragma", .sqlite, "\"pragma\""), + ]) + func engineReservedWord(name: String, type: DatabaseType, expected: String) { + #expect(CompletionIdentifierQuoting.quoted(name, for: type) == expected) + } + + @Test("a word only another engine reserves stays bare") + func otherEngineReservedWord() { + #expect(CompletionIdentifierQuoting.quoted("user", for: .mysql) == "user") + #expect(CompletionIdentifierQuoting.quoted("size", for: .postgresql) == "size") + #expect(CompletionIdentifierQuoting.quoted("pragma", for: .mssql) == "pragma") + } + + @Test("a character outside the bare set or a leading digit needs quotes") + func characters() { + #expect(CompletionIdentifierQuoting.quoted("Order Details", for: .mssql) == "[Order Details]") + #expect(CompletionIdentifierQuoting.quoted("order-items", for: .postgresql) == "\"order-items\"") + #expect(CompletionIdentifierQuoting.quoted("2fa", for: .postgresql) == "\"2fa\"") + #expect(CompletionIdentifierQuoting.quoted("2fa", for: .sqlite) == "\"2fa\"") + #expect(CompletionIdentifierQuoting.quoted("2fa", for: .mysql) == "2fa") + #expect(CompletionIdentifierQuoting.quoted("2024", for: .mysql) == "`2024`") + #expect(CompletionIdentifierQuoting.quoted("1e3", for: .mysql) == "`1e3`") + #expect(CompletionIdentifierQuoting.quoted("0x1F", for: .mariadb) == "`0x1F`") + #expect(CompletionIdentifierQuoting.quoted("0b101", for: .mysql) == "`0b101`") + #expect(CompletionIdentifierQuoting.quoted("_AUDIT", for: .oracle) == "\"_AUDIT\"") + } + + @Test("each engine's extra identifier characters stay bare") + func engineCharacters() { + #expect(CompletionIdentifierQuoting.quoted("V$SESSION", for: .oracle) == "V$SESSION") + #expect(CompletionIdentifierQuoting.quoted("LOG#1", for: .oracle) == "LOG#1") + #expect(CompletionIdentifierQuoting.quoted("price$", for: .postgresql) == "price$") + #expect(CompletionIdentifierQuoting.quoted("price$", for: .mysql) == "price$") + #expect(CompletionIdentifierQuoting.quoted("audit#log", for: .mssql) == "audit#log") + #expect(CompletionIdentifierQuoting.quoted("price$", for: .duckdb) == "\"price$\"") + #expect(CompletionIdentifierQuoting.quoted("café", for: .postgresql) == "café") + #expect(CompletionIdentifierQuoting.quoted("café", for: .mysql) == "café") + #expect(CompletionIdentifierQuoting.quoted("CAFÉ", for: .oracle) == "\"CAFÉ\"") + } + + @Test("the closing quote inside a name is doubled") + func doubledClose() { + #expect(CompletionIdentifierQuoting.quoted("say \"hi\"", for: .postgresql) == "\"say \"\"hi\"\"\"") + #expect(CompletionIdentifierQuoting.quoted("a]b", for: .mssql) == "[a]]b]") + #expect(CompletionIdentifierQuoting.quoted("a`b", for: .mysql) == "`a``b`") + } + + @Test("a name read from the statement keeps the case it was written in") + func asWritten() { + #expect(CompletionIdentifierQuoting.quotedAsWritten("O", for: .postgresql) == "O") + #expect(CompletionIdentifierQuoting.quotedAsWritten("o", for: .oracle) == "o") + #expect(CompletionIdentifierQuoting.quotedAsWritten("order line", for: .postgresql) == "\"order line\"") + #expect(CompletionIdentifierQuoting.quotedAsWritten("order line", for: .mssql) == "[order line]") + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionCatalogTests.swift b/apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionCatalogTests.swift new file mode 100644 index 0000000000..1a39e6c40e --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionCatalogTests.swift @@ -0,0 +1,329 @@ +import Foundation +import TableProAutocomplete +@testable import TableProMobile +import TableProModels +import Testing + +struct QueryCompletionCatalogTests { + private func table(_ name: String, _ type: TableInfo.TableKind = .table) -> TableInfo { + TableInfo(name: name, type: type) + } + + private func column(_ name: String, _ position: Int = 0) -> ColumnInfo { + ColumnInfo(name: name, typeName: "integer", ordinalPosition: position) + } + + private func catalog( + _ source: CatalogColumnSource, + tables: [TableInfo], + type: DatabaseType = .sqlite, + schema: String? = nil + ) -> QueryCompletionCatalog { + QueryCompletionCatalog(tables: tables, databaseType: type, schema: schema, readColumns: source.reader) + } + + private func labels(_ items: [SQLCompletionItem]) -> [String] { + items.map { $0.label } + } + + @Test("a table's columns are read once, by concurrent and later requests alike") + func oneReadPerTable() async { + let source = CatalogColumnSource(["orders": [column("id")], "customers": [column("name")]], held: true) + let catalog = catalog(source, tables: [table("orders"), table("customers")]) + + let first = Task { await catalog.columnCompletionItems(for: "orders", schema: nil) } + await source.waitForRead() + let second = Task { await catalog.columnCompletionItems(for: "orders", schema: nil) } + await source.release() + let firstItems = await first.value + let secondItems = await second.value + let later = await catalog.columnCompletionItems(for: "orders", schema: nil) + _ = await catalog.columnCompletionItems(for: "customers", schema: nil) + + #expect(labels(firstItems) == ["id"]) + #expect(labels(secondItems) == ["id"]) + #expect(labels(later) == ["id"]) + #expect(await source.reads == ["orders", "customers"]) + } + + @Test("a failed read is not kept, so the next request reads again") + func failedReadIsRetried() async { + let source = CatalogColumnSource(["orders": [column("id")]], failures: 1) + let catalog = catalog(source, tables: [table("orders")]) + + let failed = await catalog.columnCompletionItems(for: "orders", schema: nil) + let retried = await catalog.columnCompletionItems(for: "orders", schema: nil) + let kept = await catalog.columnCompletionItems(for: "orders", schema: nil) + + #expect(failed.isEmpty) + #expect(labels(retried) == ["id"]) + #expect(labels(kept) == ["id"]) + #expect(await source.reads == ["orders", "orders"]) + } + + @Test("a reader with no session keeps nothing") + func noSessionKeepsNothing() async { + let source = CatalogColumnSource(["orders": [column("id")]], sessionlessReads: 1) + let catalog = catalog(source, tables: [table("orders")]) + + let none = await catalog.columnCompletionItems(for: "orders", schema: nil) + let read = await catalog.columnCompletionItems(for: "orders", schema: nil) + + #expect(none.isEmpty) + #expect(labels(read) == ["id"]) + #expect(await source.reads == ["orders", "orders"]) + } + + @Test("nothing is read while column reads are closed, and what was read stays offered") + func noReadWhileClosed() async { + let source = CatalogColumnSource(["orders": [column("id")], "customers": [column("name")]]) + let catalog = catalog(source, tables: [table("orders"), table("customers")]) + + _ = await catalog.columnCompletionItems(for: "orders", schema: nil) + await catalog.setColumnReadsAllowed(false) + let unread = await catalog.columnCompletionItems(for: "customers", schema: nil) + let cached = await catalog.columnCompletionItems(for: "orders", schema: nil) + #expect(unread.isEmpty) + #expect(labels(cached) == ["id"]) + #expect(await source.reads == ["orders"]) + + await catalog.setColumnReadsAllowed(true) + let reopened = await catalog.columnCompletionItems(for: "customers", schema: nil) + #expect(labels(reopened) == ["name"]) + #expect(await source.reads == ["orders", "customers"]) + } + + @Test("an alias, a table name and a listed table all resolve to the listed spelling") + func caseMapping() async { + let source = CatalogColumnSource(["Orders": [column("id")]]) + let catalog = catalog(source, tables: [table("Orders")]) + let aliased = [TableReference(tableName: "orders", alias: "o")] + + #expect(await catalog.resolveAlias("O", in: aliased) == "Orders") + #expect(await catalog.resolveAlias("ORDERS", in: [TableReference(tableName: "orders", alias: nil)]) == "Orders") + #expect(await catalog.resolveAlias("orders", in: []) == "Orders") + #expect(await catalog.resolveAlias("missing", in: []) == nil) + + let items = await catalog.columnCompletionItems(for: "orders", schema: nil) + #expect(labels(items) == ["id"]) + #expect(await source.reads == ["Orders"]) + } + + @Test("a table outside the list is read as the statement names it") + func unlistedTable() async { + let source = CatalogColumnSource(["audit_log": [column("id")]]) + let catalog = catalog(source, tables: [table("orders")]) + + #expect(await catalog.resolveAlias("a", in: [TableReference(tableName: "audit_log", alias: "a")]) == "audit_log") + _ = await catalog.columnCompletionItems(for: "audit_log", schema: nil) + #expect(await source.reads == ["audit_log"]) + } + + @Test("tables and columns insert quoted for the engine, a qualified column per part") + func perPartQuoting() async { + let source = CatalogColumnSource([ + "Orders": [column("Total", 0), column("id", 1)], + "customers": [column("name")], + ]) + let catalog = catalog( + source, + tables: [table("Orders"), table("customers"), table("Recent Orders", .view)], + type: .postgresql, + schema: "public" + ) + + let tables = await catalog.tableCompletionItems() + #expect(tables.map { $0.insertText } == ["\"Orders\"", "customers", "\"Recent Orders\""]) + #expect(tables.map { $0.kind } == [.table, .table, .view]) + + let aliased = await catalog.allColumnsInScope(for: [ + TableReference(tableName: "Orders", alias: "O"), + TableReference(tableName: "customers", alias: "c"), + ]) + #expect(aliased.map { $0.label } == ["O.Total", "O.id", "c.name"]) + #expect(aliased.map { $0.insertText } == ["O.\"Total\"", "O.id", "c.name"]) + + let unaliased = await catalog.allColumnsInScope(for: [ + TableReference(tableName: "orders", alias: nil), + TableReference(tableName: "customers", alias: nil), + ]) + #expect(unaliased.map { $0.insertText } == ["\"Orders\".\"Total\"", "\"Orders\".id", "customers.name"]) + + let single = await catalog.columnCompletionItems(for: "Orders", schema: nil) + #expect(single.map { $0.insertText } == ["\"Total\"", "id"]) + } + + @Test("a qualifier quoted in the statement keeps its quotes, so it does not fold to another name") + func quotedQualifier() async { + let source = CatalogColumnSource(["orders": [column("id")], "Audit": [column("at")]]) + let catalog = catalog(source, tables: [table("orders")], type: .postgresql, schema: "public") + + let items = await catalog.allColumnsInScope(for: [ + TableReference(tableName: "orders", alias: "O", isIdentifierQuoted: true), + TableReference(tableName: "Audit", alias: nil, schema: "sales", isIdentifierQuoted: true), + TableReference(tableName: "orders", alias: "o"), + ]) + #expect(items.map { $0.insertText } == ["\"O\".id", "\"Audit\".at", "o.id"]) + } + + @Test("two unaliased tables of one name keep their schemas apart") + func sameNameInTwoSchemas() async { + let source = CatalogColumnSource(["orders": [column("id")]]) + let catalog = catalog(source, tables: [table("orders")], type: .postgresql, schema: "public") + + let items = await catalog.allColumnsInScope(for: [ + TableReference(tableName: "orders", alias: nil, schema: "public"), + TableReference(tableName: "orders", alias: nil, schema: "sales"), + ]) + #expect(items.map { $0.label } == ["public.orders.id", "sales.orders.id"]) + #expect(items.map { $0.insertText } == ["public.orders.id", "sales.orders.id"]) + } + + @Test("columns marked stale are read again on their next use, and a failed read keeps them") + func staleColumnsReadAgain() async { + let source = CatalogColumnSource(["orders": [column("id")]]) + let catalog = catalog(source, tables: [table("orders")]) + + _ = await catalog.columnCompletionItems(for: "orders", schema: nil) + await source.setColumns(["orders": [column("id"), column("total", 1)]]) + await catalog.markColumnsStale() + let refreshed = await catalog.columnCompletionItems(for: "orders", schema: nil) + let cached = await catalog.columnCompletionItems(for: "orders", schema: nil) + + await source.failNextReads(1) + await catalog.markColumnsStale() + let kept = await catalog.columnCompletionItems(for: "orders", schema: nil) + + #expect(labels(refreshed) == ["id", "total"]) + #expect(labels(cached) == ["id", "total"]) + #expect(labels(kept) == ["id", "total"]) + #expect(await source.reads == ["orders", "orders", "orders"]) + } + + @Test("a column on its own inserts without its table") + func singleReferenceIsUnqualified() async { + let source = CatalogColumnSource(["orders": [column("id")]]) + let catalog = catalog(source, tables: [table("orders")]) + + let items = await catalog.allColumnsInScope(for: [TableReference(tableName: "orders", alias: "o")]) + #expect(items.map { $0.label } == ["id"]) + #expect(items.map { $0.insertText } == ["id"]) + } + + @Test("the only known schema is the active one") + func activeSchemaOnly() async { + let source = CatalogColumnSource([:]) + let catalog = catalog(source, tables: [table("orders")], type: .postgresql, schema: "public") + + let active = await catalog.tableCompletionItems(inSchema: "public") + let other = await catalog.tableCompletionItems(inSchema: "sales") + #expect(await catalog.isKnownSchema("PUBLIC")) + #expect(await !catalog.isKnownSchema("sales")) + #expect(await !catalog.isKnownDatabase("postgres")) + #expect(labels(active) == ["orders"]) + #expect(other.isEmpty) + } + + @Test("a schema-qualified table in another schema is read with that schema") + func foreignSchemaRead() async { + let source = CatalogColumnSource(["orders": [column("id")]]) + let catalog = catalog(source, tables: [table("Orders")], type: .postgresql, schema: "public") + + _ = await catalog.columnCompletionItems(for: "orders", schema: "sales") + _ = await catalog.columnCompletionItems(for: "orders", schema: "public") + + #expect(await source.readSchemas == ["sales", nil]) + #expect(await source.reads == ["orders", "Orders"]) + } + + @Test("cached columns answer a statement that names no table, qualified where two tables share a name") + func cachedColumns() async { + let source = CatalogColumnSource(["orders": [column("id"), column("total", 1)], "customers": [column("id")]]) + let catalog = catalog(source, tables: [table("orders"), table("customers")]) + + #expect(await catalog.allColumnsFromCachedTables().isEmpty) + _ = await catalog.columnCompletionItems(for: "orders", schema: nil) + _ = await catalog.columnCompletionItems(for: "customers", schema: nil) + + let items = await catalog.allColumnsFromCachedTables() + #expect(labels(items) == ["customers.id", "orders.id", "total"]) + #expect(await source.reads == ["orders", "customers"]) + } + + @Test("new tables replace the list and keep the columns already read") + func replaceTables() async { + let source = CatalogColumnSource(["orders": [column("id")]]) + let catalog = catalog(source, tables: [table("orders")]) + + _ = await catalog.columnCompletionItems(for: "orders", schema: nil) + await catalog.replaceTables([table("orders"), table("invoices")]) + + let tables = await catalog.tableCompletionItems() + let columns = await catalog.columnCompletionItems(for: "orders", schema: nil) + #expect(labels(tables) == ["orders", "invoices"]) + #expect(labels(columns) == ["id"]) + #expect(await source.reads == ["orders"]) + } +} + +private actor CatalogColumnSource { + private struct ReadFailure: Error {} + + private var columns: [String: [ColumnInfo]] + private var isHeld: Bool + private var failures: Int + private var sessionlessReads: Int + private var heldReads: [CheckedContinuation] = [] + private var readWaiters: [CheckedContinuation] = [] + private(set) var reads: [String] = [] + private(set) var readSchemas: [String?] = [] + + init(_ columns: [String: [ColumnInfo]], held: Bool = false, failures: Int = 0, sessionlessReads: Int = 0) { + self.columns = columns + self.isHeld = held + self.failures = failures + self.sessionlessReads = sessionlessReads + } + + nonisolated var reader: QueryColumnReader { + { table, schema in try await self.read(table, schema) } + } + + func waitForRead() async { + guard reads.isEmpty else { return } + await withCheckedContinuation { readWaiters.append($0) } + } + + func release() { + isHeld = false + heldReads.forEach { $0.resume() } + heldReads = [] + } + + func setColumns(_ columns: [String: [ColumnInfo]]) { + self.columns = columns + } + + func failNextReads(_ count: Int) { + failures = count + } + + private func read(_ table: String, _ schema: String?) async throws -> [ColumnInfo]? { + reads.append(table) + readSchemas.append(schema) + readWaiters.forEach { $0.resume() } + readWaiters = [] + if isHeld { + await withCheckedContinuation { heldReads.append($0) } + } + if failures > 0 { + failures -= 1 + throw ReadFailure() + } + if sessionlessReads > 0 { + sessionlessReads -= 1 + return nil + } + return columns[table] ?? [] + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionControllerTests.swift b/apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionControllerTests.swift new file mode 100644 index 0000000000..3936b92e13 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Completion/QueryCompletionControllerTests.swift @@ -0,0 +1,496 @@ +import Foundation +import TableProAutocomplete +import TableProDatabase +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +struct QueryCompletionControllerTests { + private static let connectionId = UUID() + private let ordersColumns = [ + ColumnInfo(name: "id", typeName: "INTEGER", ordinalPosition: 0), + ColumnInfo(name: "total", typeName: "REAL", ordinalPosition: 1), + ] + + private func scope( + _ type: DatabaseType = .sqlite, + tables: [String] = ["orders"], + database: String = "main", + schema: String = "", + isRunning: Bool = false, + sessionID: ObjectIdentifier? = nil + ) -> QueryCompletionScope { + QueryCompletionScope( + connectionId: Self.connectionId, + sessionID: sessionID, + database: database, + schema: schema, + databaseType: type, + tables: tables.map { TableInfo(name: $0) }, + isRunning: isRunning + ) + } + + private func controller(_ scope: QueryCompletionScope, source: ControllerColumnSource) -> QueryCompletionController { + let controller = QueryCompletionController() + controller.update(scope: scope, readColumns: source.reader) + return controller + } + + private func suggest(_ controller: QueryCompletionController, _ text: String, caret: Int? = nil) async -> [QuerySuggestion] { + let offset = caret ?? (text as NSString).length + controller.request(text: text, selection: NSRange(location: offset, length: 0), hasMarkedText: false) + await controller.settle() + return controller.suggestions + } + + private func accept( + _ label: String, + in text: String, + caret: Int? = nil, + with controller: QueryCompletionController + ) throws -> QueryCompletionEdit { + let suggestion = try #require(controller.suggestions.first { $0.label == label }) + let selection = NSRange(location: caret ?? (text as NSString).length, length: 0) + return try #require(controller.edit(for: suggestion, in: text, selection: selection)) + } + + private func applying(_ edit: QueryCompletionEdit, to text: String) -> String { + (text as NSString).replacingCharacters(in: edit.range, with: edit.text) + } + + @Test("a FROM prefix offers the table and an alias with a dot offers its columns") + func doneCases() async { + let source = ControllerColumnSource(["orders": ordersColumns]) + let controller = controller(scope(), source: source) + + let tables = await suggest(controller, "SELECT * FROM or") + #expect(tables.first?.label == "orders") + #expect(tables.first?.kind == .table) + + let columns = await suggest(controller, "SELECT o. FROM orders o", caret: 9) + #expect(columns.map { $0.label } == ["id", "total"]) + #expect(columns.allSatisfy { $0.kind == .column }) + } + + @Test("a string or a comment offers nothing") + func stringAndCommentAreSilent() async { + let controller = controller(scope(), source: ControllerColumnSource(["orders": ordersColumns])) + + #expect(await !suggest(controller, "SELECT * FROM or").isEmpty) + #expect(await suggest(controller, "SELECT * FROM orders WHERE note = 'or").isEmpty) + #expect(await suggest(controller, "SELECT * FROM orders -- or").isEmpty) + } + + @Test("Redis completes nothing") + func redisIsOff() async { + let controller = controller(scope(.redis), source: ControllerColumnSource([:])) + + #expect(await suggest(controller, "SELECT * FROM or").isEmpty) + } + + @Test("marked text or a selection offers nothing") + func markedTextAndSelection() async { + let controller = controller(scope(), source: ControllerColumnSource([:])) + let text = "SELECT * FROM or" + + controller.request(text: text, selection: NSRange(location: 16, length: 0), hasMarkedText: true) + await controller.settle() + #expect(controller.suggestions.isEmpty) + + controller.request(text: text, selection: NSRange(location: 14, length: 2), hasMarkedText: false) + await controller.settle() + #expect(controller.suggestions.isEmpty) + } + + @Test("an opening bracket is replaced, so the name is never left with a stray bracket") + func openBracket() async throws { + let controller = controller( + scope(.mssql, tables: ["orders", "Order Details"], database: "shop", schema: "dbo"), + source: ControllerColumnSource([:]) + ) + let text = "SELECT * FROM [ord" + + _ = await suggest(controller, text) + let bare = try accept("orders", in: text, with: controller) + #expect(bare == QueryCompletionEdit(range: NSRange(location: 14, length: 4), text: "orders", caret: 20)) + #expect(applying(bare, to: text) == "SELECT * FROM orders") + + _ = await suggest(controller, text) + let bracketed = try accept("Order Details", in: text, with: controller) + #expect(applying(bracketed, to: text) == "SELECT * FROM [Order Details]") + #expect(bracketed.caret == 29) + } + + @Test("a caret inside a closed bracket pair takes the bare name and moves past the close") + func closedBracketPair() async throws { + let controller = controller( + scope(.mssql, tables: ["orders", "Order Details"], database: "shop", schema: "dbo"), + source: ControllerColumnSource([:]) + ) + let text = "SELECT * FROM [ord]" + + _ = await suggest(controller, text, caret: 18) + let edit = try accept("orders", in: text, caret: 18, with: controller) + #expect(edit == QueryCompletionEdit(range: NSRange(location: 15, length: 3), text: "orders", caret: 22)) + #expect(applying(edit, to: text) == "SELECT * FROM [orders]") + + _ = await suggest(controller, text, caret: 18) + let spaced = try accept("Order Details", in: text, caret: 18, with: controller) + #expect(applying(spaced, to: text) == "SELECT * FROM [Order Details]") + } + + @Test("PostgreSQL quotes a mixed-case table, and inside quotes it takes the name as is") + func postgreSQLQuoting() async throws { + let controller = controller( + scope(.postgresql, tables: ["Orders"], database: "shop", schema: "public"), + source: ControllerColumnSource([:]) + ) + + let bare = "SELECT * FROM Ord" + _ = await suggest(controller, bare) + #expect(applying(try accept("Orders", in: bare, with: controller), to: bare) == "SELECT * FROM \"Orders\"") + + let quoted = "SELECT * FROM \"Ord\"" + _ = await suggest(controller, quoted, caret: 18) + let edit = try accept("Orders", in: quoted, caret: 18, with: controller) + #expect(edit == QueryCompletionEdit(range: NSRange(location: 15, length: 3), text: "Orders", caret: 22)) + #expect(applying(edit, to: quoted) == "SELECT * FROM \"Orders\"") + } + + @Test("a keyword is inserted with a trailing space in the case it was typed") + func keywordSpace() async throws { + let controller = controller(scope(), source: ControllerColumnSource([:])) + + _ = await suggest(controller, "sel") + let edit = try accept("select", in: "sel", with: controller) + #expect(edit == QueryCompletionEdit(range: NSRange(location: 0, length: 3), text: "select ", caret: 7)) + } + + @Test("an accepted name is not offered again until the text moves") + func noRepeatAfterAccept() async throws { + let controller = controller(scope(), source: ControllerColumnSource([:])) + let text = "SELECT * FROM or" + + _ = await suggest(controller, text) + let edit = try accept("orders", in: text, with: controller) + #expect(edit.caret == 20) + #expect(controller.suggestions.isEmpty) + + let accepted = applying(edit, to: text) + #expect(await suggest(controller, accepted, caret: edit.caret).isEmpty) + #expect(await !suggest(controller, accepted + " ").isEmpty) + } + + @Test("an edit for text that has since changed is refused") + func movedTextGetsNoEdit() async { + let controller = controller(scope(), source: ControllerColumnSource([:])) + + let suggestions = await suggest(controller, "SELECT * FROM or") + let first = suggestions.first + #expect(first != nil) + if let first { + #expect(controller.edit(for: first, in: "SELECT * FROM ord", selection: NSRange(location: 17, length: 0)) == nil) + } + } + + @Test("an answer for another caret position is refused") + func movedCaretGetsNoEdit() async throws { + let controller = controller(scope(), source: ControllerColumnSource([:])) + let text = "SELECT * FROM or" + + let first = try #require(await suggest(controller, text).first) + #expect(controller.edit(for: first, in: text, selection: NSRange(location: 15, length: 0)) == nil) + #expect(controller.edit(for: first, in: text, selection: NSRange(location: 16, length: 0)) != nil) + } + + @Test("a caret inside a quoted name with a space takes the whole name") + func quotedNameWithSpace() async throws { + let controller = controller( + scope(.postgresql, tables: ["Order Details", "orders"], database: "shop", schema: "public"), + source: ControllerColumnSource([:]) + ) + let text = "SELECT * FROM \"Order Details\"" + + _ = await suggest(controller, text, caret: 18) + let edit = try accept("Order Details", in: text, caret: 18, with: controller) + #expect(edit == QueryCompletionEdit(range: NSRange(location: 15, length: 13), text: "Order Details", caret: 29)) + #expect(applying(edit, to: text) == text) + + let partial = "SELECT * FROM \"Order Det\"" + _ = await suggest(controller, partial, caret: 18) + let completed = try accept("Order Details", in: partial, caret: 18, with: controller) + #expect(applying(completed, to: partial) == "SELECT * FROM \"Order Details\"") + } + + @Test("a bracketed name typed with a space is replaced from its opening bracket") + func bracketedNameWithSpace() async throws { + let controller = controller( + scope(.mssql, tables: ["Order Details"], database: "shop", schema: "dbo"), + source: ControllerColumnSource([:]) + ) + + let closed = "SELECT * FROM [Order Det]" + _ = await suggest(controller, closed, caret: 24) + let inside = try accept("Order Details", in: closed, caret: 24, with: controller) + #expect(applying(inside, to: closed) == "SELECT * FROM [Order Details]") + #expect(inside.caret == 29) + + let open = "SELECT * FROM [Order Det" + _ = await suggest(controller, open) + #expect(applying(try accept("Order Details", in: open, with: controller), to: open) == "SELECT * FROM [Order Details]") + } + + @Test("a typed quote whose close belongs to later text leaves that text alone") + func laterCloseIsNotTheName() async throws { + let controller = controller( + scope(.postgresql, tables: ["Orders"], database: "shop", schema: "public"), + source: ControllerColumnSource([:]) + ) + let text = "SELECT * FROM \"Ord WHERE \"status\" = 1" + + _ = await suggest(controller, text, caret: 18) + let edit = try accept("Orders", in: text, caret: 18, with: controller) + #expect(applying(edit, to: text) == "SELECT * FROM \"Orders\" WHERE \"status\" = 1") + } + + @Test("a qualified column replaces the quotes typed around its name") + func qualifiedColumnInQuotes() async throws { + let source = ControllerColumnSource(["orders": ordersColumns, "customers": [ColumnInfo(name: "name", typeName: "TEXT")]]) + let controller = controller(scope(tables: ["orders", "customers"]), source: source) + let text = "SELECT \"to\" FROM orders o, customers c" + + _ = await suggest(controller, text, caret: 10) + let edit = try accept("o.total", in: text, caret: 10, with: controller) + #expect(applying(edit, to: text) == "SELECT o.total FROM orders o, customers c") + #expect(edit.caret == 14) + } + + @Test("hiding keeps the bar empty until the caret moves") + func hideUntilMoved() async { + let controller = controller(scope(), source: ControllerColumnSource([:])) + let text = "SELECT * FROM or" + + #expect(await !suggest(controller, text).isEmpty) + controller.hide() + #expect(controller.suggestions.isEmpty) + #expect(await suggest(controller, text).isEmpty) + #expect(await !suggest(controller, text, caret: 15).isEmpty) + } + + @Test("WHERE with nothing typed still fills the bar") + func emptyWhere() async { + let controller = controller(scope(), source: ControllerColumnSource(["orders": ordersColumns])) + + #expect(await !suggest(controller, "SELECT * FROM orders o WHERE ").isEmpty) + } + + @Test("UPDATE with nothing typed leads with tables") + func emptyUpdate() async { + let controller = controller(scope(), source: ControllerColumnSource([:])) + + let suggestions = await suggest(controller, "UPDATE ") + #expect(suggestions.first?.label == "orders") + #expect(suggestions.first?.kind == .table) + } + + @Test("requests made in one turn run once, on the newest text") + func requestsCoalesce() async { + let source = ControllerColumnSource(["orders": ordersColumns]) + let controller = controller(scope(), source: source) + + controller.request(text: "SELECT o. FROM orders o", selection: NSRange(location: 9, length: 0), hasMarkedText: false) + controller.request(text: "SELECT * FROM or", selection: NSRange(location: 16, length: 0), hasMarkedText: false) + await controller.settle() + + #expect(await source.reads.isEmpty) + #expect(controller.suggestions.first?.label == "orders") + } + + @Test("an answer that lands after a newer request is dropped") + func lateAnswerIsDropped() async { + let source = ControllerColumnSource(["orders": ordersColumns], held: true) + let controller = controller(scope(), source: source) + + controller.request(text: "SELECT o. FROM orders o", selection: NSRange(location: 9, length: 0), hasMarkedText: false) + await source.waitForRead() + controller.onSuggestionsChange = { suggestions in + guard suggestions.first?.label == "orders" else { return } + Task { await source.release() } + } + controller.request(text: "SELECT * FROM or", selection: NSRange(location: 16, length: 0), hasMarkedText: false) + await controller.settle() + + #expect(controller.suggestions.first?.label == "orders") + #expect(!controller.suggestions.contains { $0.kind == .column }) + #expect(await source.reads == ["orders"]) + } + + @Test("no column is read while a run streams or as it ends, only on the next request") + func noReadWhileRunning() async { + let source = ControllerColumnSource(["orders": ordersColumns]) + let controller = controller(scope(isRunning: true), source: source) + + let during = await suggest(controller, "SELECT o. FROM orders o", caret: 9) + #expect(!during.contains { $0.kind == .column }) + #expect(await source.reads.isEmpty) + + controller.update(scope: scope(isRunning: false), readColumns: source.reader) + await controller.settle() + #expect(await source.reads.isEmpty) + + let after = await suggest(controller, "SELECT o. FROM orders o", caret: 9) + #expect(after.map { $0.label } == ["id", "total"]) + #expect(await source.reads == ["orders"]) + } + + @Test("a database switch reads columns again") + func databaseSwitchReadsAgain() async { + let source = ControllerColumnSource(["orders": ordersColumns]) + let controller = controller(scope(.mysql, database: "shop"), source: source) + + _ = await suggest(controller, "SELECT o. FROM orders o", caret: 9) + controller.update(scope: scope(.mysql, database: "archive"), readColumns: source.reader) + await controller.settle() + + #expect(controller.suggestions.map { $0.label } == ["id", "total"]) + #expect(await source.reads == ["orders", "orders"]) + } + + @Test("a new session reads columns again") + func newSessionReadsAgain() async { + let source = ControllerColumnSource(["orders": ordersColumns]) + let first = NSObject() + let second = NSObject() + let controller = controller(scope(sessionID: ObjectIdentifier(first)), source: source) + + _ = await suggest(controller, "SELECT o. FROM orders o", caret: 9) + controller.update(scope: scope(sessionID: ObjectIdentifier(second)), readColumns: source.reader) + await controller.settle() + + #expect(controller.suggestions.map { $0.label } == ["id", "total"]) + #expect(await source.reads == ["orders", "orders"]) + #expect(first !== second) + } + + @Test("a run's end reads cached columns again") + func runEndReadsColumnsAgain() async { + let source = ControllerColumnSource(["orders": ordersColumns]) + let controller = controller(scope(), source: source) + + _ = await suggest(controller, "SELECT o. FROM orders o", caret: 9) + await source.setColumns(["orders": ordersColumns + [ColumnInfo(name: "status", typeName: "TEXT", ordinalPosition: 2)]]) + controller.update(scope: scope(isRunning: true), readColumns: source.reader) + controller.update(scope: scope(isRunning: false), readColumns: source.reader) + await controller.settle() + #expect(await source.reads == ["orders"]) + + let after = await suggest(controller, "SELECT o. FROM orders o", caret: 9) + #expect(after.map { $0.label } == ["id", "total", "status"]) + #expect(await source.reads == ["orders", "orders"]) + } + + @Test("a new database drops the old suggestions before its own arrive") + func scopeChangeDropsTheOldAnswer() async throws { + let controller = controller(scope(database: "shop"), source: ControllerColumnSource([:])) + let text = "SELECT * FROM or" + + let old = try #require(await suggest(controller, text).first) + controller.update(scope: scope(tables: ["orders"], database: "archive"), readColumns: ControllerColumnSource([:]).reader) + #expect(controller.suggestions.isEmpty) + #expect(controller.edit(for: old, in: text, selection: NSRange(location: 16, length: 0)) == nil) + + await controller.settle() + #expect(controller.suggestions.first?.label == "orders") + } + + @Test("a run waits for a column read already sent") + func runWaitsForSentRead() async { + let source = ControllerColumnSource(["orders": ordersColumns], held: true) + let controller = controller(scope(), source: source) + + controller.request(text: "SELECT o. FROM orders o", selection: NSRange(location: 9, length: 0), hasMarkedText: false) + await source.waitForRead() + let fence = Task { + await controller.finishColumnReads() + return await source.finishedReads + } + await source.release() + + #expect(await fence.value == 1) + await controller.settle() + } + + @Test("the column reader asks whichever session is current") + func readerFollowsSession() async throws { + let first = MockDatabaseDriver() + first.scriptedColumns = [ColumnInfo(name: "id", typeName: "INTEGER")] + let second = MockDatabaseDriver() + second.scriptedColumns = [ColumnInfo(name: "total", typeName: "REAL")] + let holder = SessionHolder() + holder.session = ConnectionSession(connectionId: Self.connectionId, driver: first, activeDatabase: "main") + let reader = QueryCompletionController.columnReader { holder.session } + + let before = try await reader("orders", nil) + holder.session = ConnectionSession(connectionId: Self.connectionId, driver: second, activeDatabase: "main") + let after = try await reader("orders", nil) + holder.session = nil + let none = try await reader("orders", nil) + + #expect(before?.map { $0.name } == ["id"]) + #expect(after?.map { $0.name } == ["total"]) + #expect(none == nil) + #expect(first.fetchColumnsCalls == 1) + #expect(second.fetchColumnsCalls == 1) + } +} + +@MainActor +private final class SessionHolder { + var session: ConnectionSession? +} + +private actor ControllerColumnSource { + private var columns: [String: [ColumnInfo]] + private var isHeld: Bool + private var heldReads: [CheckedContinuation] = [] + private var readWaiters: [CheckedContinuation] = [] + private(set) var reads: [String] = [] + private(set) var finishedReads = 0 + + init(_ columns: [String: [ColumnInfo]], held: Bool = false) { + self.columns = columns + self.isHeld = held + } + + nonisolated var reader: QueryColumnReader { + { table, schema in await self.read(table, schema) } + } + + func waitForRead() async { + guard reads.isEmpty else { return } + await withCheckedContinuation { readWaiters.append($0) } + } + + func release() { + isHeld = false + heldReads.forEach { $0.resume() } + heldReads = [] + } + + func setColumns(_ columns: [String: [ColumnInfo]]) { + self.columns = columns + } + + private func read(_ table: String, _ schema: String?) async -> [ColumnInfo]? { + reads.append(table) + readWaiters.forEach { $0.resume() } + readWaiters = [] + if isHeld { + await withCheckedContinuation { heldReads.append($0) } + } + finishedReads += 1 + return columns[table] ?? [] + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/SQLEditorTextViewKeyCommandTests.swift b/apple/TableProMobile/TableProMobileTests/Views/SQLEditorTextViewKeyCommandTests.swift new file mode 100644 index 0000000000..c7670c0ed0 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/SQLEditorTextViewKeyCommandTests.swift @@ -0,0 +1,157 @@ +import Foundation +import SwiftUI +import TableProAutocomplete +@testable import TableProMobile +import Testing +import UIKit + +@MainActor +struct SQLEditorTextViewKeyCommandTests { + @Test("Tab and Escape stay with the text system while no suggestion shows") + func noCommandsWithoutSuggestions() { + let textView = SQLEditorTextView() + #expect(command(in: textView, input: "\t") == nil) + #expect(command(in: textView, input: UIKeyCommand.inputEscape) == nil) + } + + @Test("While suggestions show, Tab and Escape are titled, unmodified and ahead of the text system") + func commandsWhileSuggestionsShow() throws { + let textView = SQLEditorTextView() + textView.showsSuggestions = true + for input in ["\t", UIKeyCommand.inputEscape] { + let keyCommand = try #require(command(in: textView, input: input)) + #expect(keyCommand.modifierFlags.isEmpty) + #expect(keyCommand.wantsPriorityOverSystemBehavior) + #expect(!keyCommand.title.isEmpty) + } + } + + @Test("Tab accepts and Escape hides, and neither can run once the suggestions are gone") + func commandsRunTheirHandlers() throws { + let textView = SQLEditorTextView() + var accepted = 0 + var hidden = 0 + textView.onAcceptSuggestion = { accepted += 1 } + textView.onHideSuggestions = { hidden += 1 } + textView.showsSuggestions = true + + let tab = try #require(command(in: textView, input: "\t")) + let escape = try #require(command(in: textView, input: UIKeyCommand.inputEscape)) + let tabAction = try #require(tab.action) + let escapeAction = try #require(escape.action) + #expect(textView.canPerformAction(tabAction, withSender: tab)) + #expect(textView.canPerformAction(escapeAction, withSender: escape)) + + _ = textView.perform(tabAction, with: tab) + _ = textView.perform(escapeAction, with: escape) + #expect(accepted == 1) + #expect(hidden == 1) + + textView.showsSuggestions = false + #expect(!textView.canPerformAction(tabAction, withSender: tab)) + #expect(!textView.canPerformAction(escapeAction, withSender: escape)) + } + + @Test("An accepted name replaces the typed token and the binding holds the result") + func acceptedNameReachesTheBinding() { + let result = applied( + QueryCompletionEdit(range: NSRange(location: 14, length: 2), text: "orders", caret: 20), + to: "SELECT * FROM or" + ) + #expect(result.text == "SELECT * FROM orders") + #expect(result.selection == NSRange(location: 20, length: 0)) + #expect(result.binding == "SELECT * FROM orders") + } + + @Test("An accepted function leaves the caret inside its parentheses") + func acceptedFunctionPlacesTheCaret() { + let result = applied( + QueryCompletionEdit(range: NSRange(location: 7, length: 3), text: "COUNT()", caret: 13), + to: "SELECT cou FROM t" + ) + #expect(result.text == "SELECT COUNT() FROM t") + #expect(result.selection == NSRange(location: 13, length: 0)) + #expect(result.binding == "SELECT COUNT() FROM t") + } + + @Test("Edit offsets count UTF-16 units, as the analyzer reports them") + func editOffsetsAreUTF16() { + let original = "-- 😀\nSELECT * FROM or" + let tokenStart = (original as NSString).length - 2 + let result = applied( + QueryCompletionEdit(range: NSRange(location: tokenStart, length: 2), text: "orders", caret: tokenStart + 6), + to: original + ) + #expect(result.text == "-- 😀\nSELECT * FROM orders") + #expect(result.binding == "-- 😀\nSELECT * FROM orders") + #expect(result.selection == NSRange(location: tokenStart + 6, length: 0)) + } + + @Test("New suggestions open the bar at the first one, while the keyboard is off screen and when it shows") + func suggestionsOpenAtTheFirst() throws { + let controller = QueryCompletionController() + let coordinator = SQLHighlightTextView(text: .constant(""), completion: controller).makeCoordinator() + let accessory = coordinator.makeAccessoryToolbar() + let textView = SQLEditorTextView() + coordinator.connectCompletion(to: textView) + let scrollView = try #require(accessory.subviews.lazy.compactMap { $0 as? UIScrollView }.first) + let columns = (0..<8).map { QuerySuggestion(id: $0, label: "BillingAddress\($0)", kind: .column) } + + controller.onSuggestionsChange?(columns) + #expect(scrollView.contentOffset.x == -scrollView.adjustedContentInset.left) + + accessory.frame = CGRect(x: 0, y: 0, width: 402, height: 44) + accessory.layoutIfNeeded() + controller.onSuggestionsChange?([]) + scrollView.contentOffset.x = 300 + controller.onSuggestionsChange?(Array(columns.reversed())) + #expect(scrollView.contentOffset.x == -scrollView.adjustedContentInset.left) + } + + @Test("Each suggestion is as wide as its own name") + func chipsFitTheirNames() { + let bar = QuerySuggestionBar() + bar.show(["Total", "InvoiceId", "BillingAddress"].enumerated().map { index, label in + QuerySuggestion(id: index, label: label, kind: .column) + }) + bar.frame = CGRect(origin: .zero, size: bar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)) + bar.layoutIfNeeded() + + var chips: [UIButton] = [] + var pending: [UIView] = [bar] + while let view = pending.popLast() { + if let button = view as? UIButton { chips.append(button) } + pending.append(contentsOf: view.subviews) + } + #expect(chips.count == 3) + for chip in chips { + #expect(abs(chip.frame.width - chip.intrinsicContentSize.width) < 0.5) + } + } + + private func command(in textView: SQLEditorTextView, input: String) -> UIKeyCommand? { + textView.keyCommands?.first { $0.input == input && $0.modifierFlags.isEmpty } + } + + private func applied(_ edit: QueryCompletionEdit, to original: String) -> (text: String, selection: NSRange, binding: String) { + let box = TextBox(original) + let editor = SQLHighlightTextView(text: Binding(get: { box.text }, set: { box.text = $0 })) + let coordinator = editor.makeCoordinator() + let textView = SQLEditorTextView() + textView.text = original + textView.delegate = coordinator + coordinator.textView = textView + + coordinator.apply(edit, to: textView) + return (textView.text ?? "", textView.selectedRange, box.text) + } +} + +@MainActor +private final class TextBox { + var text: String + + init(_ text: String) { + self.text = text + } +} diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 30b884e1f4..70a17779c6 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -113,6 +113,7 @@ targets: - package: TableProCore products: - TableProAnalytics + - TableProAutocomplete - TableProConnectionLibrary - TableProCoreTypes - TableProDatabase diff --git a/apple/TableProTests/Core/Autocomplete/SQLCompletionServiceTokenStartTests.swift b/apple/TableProTests/Core/Autocomplete/SQLCompletionServiceTokenStartTests.swift new file mode 100644 index 0000000000..9cff826466 --- /dev/null +++ b/apple/TableProTests/Core/Autocomplete/SQLCompletionServiceTokenStartTests.swift @@ -0,0 +1,32 @@ +import Foundation +@testable import TablePro +import Testing + +// The editor closes `[` and `"` as they are typed, so the caret sits before the close and the bare name goes between. +struct SQLCompletionServiceTokenStartTests { + @MainActor + @Test( + "A closed pair starts the token after its opening quote", + arguments: [ + (DatabaseType.mssql, "SELECT * FROM [ord]", "SELECT * FROM [orders]"), + (DatabaseType.postgresql, "SELECT * FROM \"ord\"", "SELECT * FROM \"orders\"") + ] + ) + func closedPairStartsAfterTheQuote(databaseType: DatabaseType, typed: String, accepted: String) { + let service = SQLCompletionService(schemaProvider: nil, databaseType: databaseType) + let text = typed as NSString + + let start = service.tokenStart(in: text, endingAt: 18) + + #expect(start == 15) + #expect(text.replacingCharacters(in: NSRange(location: start, length: 18 - start), with: "orders") == accepted) + } + + @MainActor + @Test("An open bracket is part of the token on SQL Server") + func openBracketIsPartOfTheToken() { + let service = SQLCompletionService(schemaProvider: nil, databaseType: .mssql) + + #expect(service.tokenStart(in: "SELECT * FROM [ord" as NSString, endingAt: 18) == 14) + } +} diff --git a/apple/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift b/apple/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift index 3be8747b31..dd90701afd 100644 --- a/apple/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift +++ b/apple/TableProTests/Views/Editor/SQLCompletionAdapterFuzzyTests.swift @@ -1,42 +1,30 @@ -// Regression tests for fuzzy matching used by autocomplete. - +import TableProAutocomplete import TableProPluginKit @testable import TablePro import Testing struct SQLCompletionAdapterFuzzyTests { - /// Helper: wraps SQLCompletionProvider.fuzzyMatchScore as a bool match - /// to preserve existing test semantics after the fuzzy logic was unified. private func fuzzyMatch(pattern: String, target: String) -> Bool { let provider = makeDummyProvider() - // Empty pattern is a vacuous match if pattern.isEmpty { return true } return provider.fuzzyMatchScore(pattern: pattern, target: target) != nil } private func makeDummyProvider() -> SQLCompletionProvider { - // Provider only needs schemaProvider for candidate generation, - // fuzzyMatchScore is pure and doesn't touch the schema. let schema = SQLSchemaProvider() return SQLCompletionProvider(schemaProvider: schema) } - // MARK: - Exact Match - @Test("Exact match returns true") func exactMatch() { #expect(fuzzyMatch(pattern: "select", target: "select") == true) } - // MARK: - Prefix Match - @Test("Prefix match returns true") func prefixMatch() { #expect(fuzzyMatch(pattern: "sel", target: "select") == true) } - // MARK: - Scattered Match - @Test("Scattered characters in order returns true") func scatteredMatch() { #expect(fuzzyMatch(pattern: "slc", target: "select") == true) @@ -52,10 +40,6 @@ struct SQLCompletionAdapterFuzzyTests { #expect(fuzzyMatch(pattern: "usr", target: "users_table") == true) } - // MARK: - No Match - - /// No matching characters, the right characters in the wrong order, a pattern longer than the - /// target, a case that differs, and one character the target lacks. @Test("A pattern that is not an in-order subsequence of the target does not match", arguments: [ "xyz", "tces", "selectfromwhere", "SELECT", "z", ]) @@ -63,8 +47,6 @@ struct SQLCompletionAdapterFuzzyTests { #expect(fuzzyMatch(pattern: pattern, target: "select") == false) } - // MARK: - Empty Pattern - @Test("Empty pattern matches anything") func emptyPatternMatchesAnything() { #expect(fuzzyMatch(pattern: "", target: "anything") == true) @@ -75,9 +57,6 @@ struct SQLCompletionAdapterFuzzyTests { #expect(fuzzyMatch(pattern: "", target: "") == true) } - - // MARK: - Unicode - @Test("ASCII pattern against accented target") func asciiPatternAccentedTarget() { #expect(fuzzyMatch(pattern: "tbl", target: "table") == true) @@ -88,8 +67,6 @@ struct SQLCompletionAdapterFuzzyTests { #expect(fuzzyMatch(pattern: "cafe", target: "cafe") == true) } - // MARK: - Large Strings - @Test("Fuzzy match with large target string") func largeTargetString() { let largeTarget = String(repeating: "a", count: 10_000) + "xyz" @@ -108,8 +85,6 @@ struct SQLCompletionAdapterFuzzyTests { #expect(fuzzyMatch(pattern: "xyz", target: largeTarget) == true) } - // MARK: - Single Characters - @Test("Single character present returns true") func singleCharPresent() { #expect(fuzzyMatch(pattern: "s", target: "select") == true) diff --git a/apple/TestPlans/Unit.xctestplan b/apple/TestPlans/Unit.xctestplan index 7c7c2527d9..49c3bc45d5 100644 --- a/apple/TestPlans/Unit.xctestplan +++ b/apple/TestPlans/Unit.xctestplan @@ -109,13 +109,6 @@ "name" : "TableProGrammarsTests" } }, - { - "target" : { - "containerPath" : "container:Packages\/TableProMacCore", - "identifier" : "TableProAutocompleteTests", - "name" : "TableProAutocompleteTests" - } - }, { "target" : { "containerPath" : "container:Packages\/TableProMacCore", diff --git a/apple/project.yml b/apple/project.yml index c386eb3bc6..23473c59aa 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -390,9 +390,9 @@ targets: - package: Sparkle - package: Yams - package: TableProCore - products: [TableProAnalytics, TableProConnectionLibrary, TableProGeometry, TableProGoogleCloud, TableProImport, TableProLogRedaction, TableProNumberFormatting, TableProRedisCore, TableProSQLGrammar, TableProSSHTransport, TableProSyncTransport, TableProTabular, TableProTabularIO, TableProTheme] + products: [TableProAnalytics, TableProAutocomplete, TableProConnectionLibrary, TableProGeometry, TableProGoogleCloud, TableProImport, TableProLogRedaction, TableProNumberFormatting, TableProRedisCore, TableProSQLGrammar, TableProSSHTransport, TableProSyncTransport, TableProTabular, TableProTabularIO, TableProTheme] - package: TableProMacCore - products: [TableProAutocomplete, TableProMCP, TableProSSHConfig, TableProUtilities] + products: [TableProMCP, TableProSSHConfig, TableProUtilities] # Writes nothing for a version CHANGELOG.md has no section for, so a 0.0.0-dev build ships no notes. postCompileScripts: - name: Write WhatsNew.md From 079013d6a95e2c1dbc13d7a9c2d057254cda5f07 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:52:07 +0700 Subject: [PATCH 2/2] docs(changelog): add the #3690 fragments --- apple/changelog.d/3690.added.md | 1 + apple/changelog.d/3690.fixed.1.md | 1 + apple/changelog.d/3690.fixed.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 apple/changelog.d/3690.added.md create mode 100644 apple/changelog.d/3690.fixed.1.md create mode 100644 apple/changelog.d/3690.fixed.md diff --git a/apple/changelog.d/3690.added.md b/apple/changelog.d/3690.added.md new file mode 100644 index 0000000000..2df5b47afe --- /dev/null +++ b/apple/changelog.d/3690.added.md @@ -0,0 +1 @@ +Table, column and keyword suggestions above the keyboard in the query editor on iPhone and iPad. diff --git a/apple/changelog.d/3690.fixed.1.md b/apple/changelog.d/3690.fixed.1.md new file mode 100644 index 0000000000..d84431f0b2 --- /dev/null +++ b/apple/changelog.d/3690.fixed.1.md @@ -0,0 +1 @@ +Autocomplete taking SET for a table alias in UPDATE statements. diff --git a/apple/changelog.d/3690.fixed.md b/apple/changelog.d/3690.fixed.md new file mode 100644 index 0000000000..23ad09761b --- /dev/null +++ b/apple/changelog.d/3690.fixed.md @@ -0,0 +1 @@ +Autocomplete offering nothing after an apostrophe in a comment, a backslash in a string, or inside a quoted table name.