diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionItem.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionItem.swift index 44a004d5df..b5892646b0 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionItem.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionItem.swift @@ -55,6 +55,8 @@ public struct SQLCompletionItem: Identifiable, Hashable, Sendable { /// language keywords, including value literals and `table.*`, and re-casing those is a /// correctness bug rather than a style choice. public let caseFolding: SQLCompletionCaseFolding + // Set on a name the catalog stores, so the connection's identifier rules can spell it. + public let identifier: SQLCompletionIdentifier? public var matchedRanges: [Range] = [] public var fuzzyPenalty: Int = 0 // `o.total` names a table and a column; a column literally named `order.total` is one name. @@ -68,7 +70,8 @@ public struct SQLCompletionItem: Identifiable, Hashable, Sendable { documentation: String? = nil, sortPriority: Int? = nil, filterText: String? = nil, - caseFolding: SQLCompletionCaseFolding = .fixed + caseFolding: SQLCompletionCaseFolding = .fixed, + identifier: SQLCompletionIdentifier? = nil ) { self.id = UUID() self.label = label @@ -79,6 +82,7 @@ public struct SQLCompletionItem: Identifiable, Hashable, Sendable { self.sortPriority = sortPriority ?? kind.basePriority self.filterText = filterText ?? label.lowercased() self.caseFolding = caseFolding + self.identifier = identifier } private init(recasing item: SQLCompletionItem, label: String, insertText: String) { @@ -91,6 +95,7 @@ public struct SQLCompletionItem: Identifiable, Hashable, Sendable { self.sortPriority = item.sortPriority self.filterText = item.filterText self.caseFolding = item.caseFolding + self.identifier = item.identifier self.matchedRanges = item.matchedRanges self.fuzzyPenalty = item.fuzzyPenalty self.isQualifiedReference = item.isQualifiedReference @@ -217,7 +222,8 @@ public extension SQLCompletionItem { label: name, kind: isView ? .view : .table, insertText: name, - detail: isView ? "View" : "Table" + detail: isView ? "View" : "Table", + identifier: .catalog(name) ) } @@ -226,7 +232,8 @@ public extension SQLCompletionItem { label: name, kind: .schema, insertText: name, - detail: String(localized: "Schema") + detail: String(localized: "Schema"), + identifier: .catalog(name) ) } @@ -235,7 +242,8 @@ public extension SQLCompletionItem { label: name, kind: .schema, insertText: name, - detail: String(localized: "Database") + detail: String(localized: "Database"), + identifier: .catalog(name) ) } @@ -246,7 +254,8 @@ public extension SQLCompletionItem { isPrimaryKey: Bool = false, isNullable: Bool = true, defaultValue: String? = nil, - comment: String? = nil + comment: String? = nil, + identifier: SQLCompletionIdentifier? = nil ) -> SQLCompletionItem { var detailParts: [String] = [] if isPrimaryKey { detailParts.append("PK") } @@ -265,7 +274,8 @@ public extension SQLCompletionItem { kind: .column, insertText: name, detail: detail, - documentation: documentation + documentation: documentation, + identifier: identifier ) } diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift index 591a2358c4..7ce6842c4e 100644 --- a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLCompletionProvider.swift @@ -213,7 +213,8 @@ public final class SQLCompletionProvider { kind: .keyword, insertText: "\(qualifier).*", detail: "All columns from \(ref.tableName)", - sortPriority: 60 + sortPriority: 60, + identifier: SQLCompletionIdentifier([Self.qualifierPart(for: ref), .statement("*", quoted: false)]) )) } items += await columnItems(for: context.tableReferences) @@ -237,7 +238,10 @@ public final class SQLCompletionProvider { insertText: "\(qualifier).\(col.label)", detail: col.detail, documentation: "Column from \(ref.tableName)", - sortPriority: 80 + sortPriority: 80, + identifier: col.identifier.map { + SQLCompletionIdentifier([Self.qualifierPart(for: ref)] + $0.parts) + } ) item.isQualifiedReference = true items.append(item) @@ -533,6 +537,11 @@ public final class SQLCompletionProvider { return await catalog?.allColumnsInScope(for: references) ?? [] } + // A reference is spelled as the statement wrote it, and stays quoted when it was, or it would fold to another name. + public static func qualifierPart(for reference: TableReference) -> SQLCompletionIdentifier.Part { + .statement(reference.identifier, quoted: reference.isIdentifierQuoted) + } + private func filterKeywords(_ keywords: [String]) -> [SQLCompletionItem] { keywords.map { SQLCompletionItem.keyword($0) } } diff --git a/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLIdentifierSpelling.swift b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLIdentifierSpelling.swift new file mode 100644 index 0000000000..5531a4ec99 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProAutocomplete/SQLIdentifierSpelling.swift @@ -0,0 +1,176 @@ +import Foundation + +public enum SQLIdentifierCase: String, Codable, CaseIterable, Sendable { + case asStored + case lower + case upper + case matchTyped + + public static let `default` = SQLIdentifierCase.asStored + + // Without a typed prefix, matching the typing has nothing to match, so it reads as stored. + public var recasesWithoutTyping: Bool { + switch self { + case .lower, .upper: return true + case .asStored, .matchTyped: return false + } + } +} + +public enum SQLIdentifierFolding: Sendable, Equatable { + case upper + case lower +} + +// How an engine resolves an unquoted name: folded to one case, unless it is reserved. +public struct SQLIdentifierRules: Sendable { + public let folding: SQLIdentifierFolding + public let reservedWords: Set + public let quote: Character + + public init(folding: SQLIdentifierFolding, reservedWords: Set, quote: Character = "\"") { + self.folding = folding + self.reservedWords = Set(reservedWords.map { $0.uppercased() }) + self.quote = quote + } +} + +// What a completion's name is made of. A catalog part is spelled by the connection's rules; a statement part is +// what the user wrote, an alias or a reference, and is never re-cased. +public struct SQLCompletionIdentifier: Sendable, Hashable { + public enum Part: Sendable, Hashable { + case catalog(String) + case statement(String, quoted: Bool) + } + + public let parts: [Part] + + public init(_ parts: [Part]) { + self.parts = parts + } + + public static func catalog(_ name: String) -> SQLCompletionIdentifier { + SQLCompletionIdentifier([.catalog(name)]) + } +} + +// Whether the token being completed already opened a quoted name, and whether that quote is closed. +public enum SQLIdentifierTokenQuoting: Sendable, Equatable { + case none + case opened + case enclosed + + public init(_ quotedName: SQLQuotedName?) { + guard let quotedName else { + self = .none + return + } + self = quotedName.close == nil ? .opened : .enclosed + } +} + +public struct SQLIdentifierSpelling: Sendable { + public enum Form: Sendable, Equatable { + case bare(String) + case quoted + } + + public let rules: SQLIdentifierRules + public let policy: SQLIdentifierCase + + public init(rules: SQLIdentifierRules, policy: SQLIdentifierCase) { + self.rules = rules + self.policy = policy + } + + // A name keeps working bare only when the engine folds it back to itself, so any other spelling of it would too. + public func form(of name: String, typedPrefix: String = "") -> Form { + guard canStayBare(name) else { return .quoted } + switch policy { + case .asStored: + return .bare(name) + case .lower: + return .bare(name.lowercased()) + case .upper: + return .bare(name.uppercased()) + case .matchTyped: + guard let scalar = typedPrefix.unicodeScalars.first(where: { $0.properties.isCased }) else { + return .bare(name) + } + return .bare(scalar.properties.isLowercase ? name.lowercased() : name.uppercased()) + } + } + + public func quoted(_ name: String) -> String { + let quote = String(rules.quote) + return quote + name.replacingOccurrences(of: quote, with: quote + quote) + quote + } + + // A name written into a statement with nothing typed: as stored keeps the caller's own spelling. + public func statementName(_ name: String, quotedBy quote: (String) -> String) -> String { + guard policy.recasesWithoutTyping, case .bare(let text) = form(of: name) else { return quote(name) } + return text + } + + // `label` and `insertText` are rewritten together; `filterText` and `matchedRanges` stay, since a re-cased name + // has the same length and every quote goes into `insertText` only. + public func applied( + to items: [SQLCompletionItem], + typedPrefix: String, + quoting: SQLIdentifierTokenQuoting + ) -> [SQLCompletionItem] { + items.map { spelled($0, typedPrefix: typedPrefix, quoting: quoting) } + } + + private func spelled( + _ item: SQLCompletionItem, + typedPrefix: String, + quoting: SQLIdentifierTokenQuoting + ) -> SQLCompletionItem { + guard let identifier = item.identifier else { return item } + if quoting != .none, identifier.parts.count == 1, case .catalog(let name) = identifier.parts[0] { + // The user's own quote asks for the exact name: inside a closed pair it goes in raw. + let quote = String(rules.quote) + let text = quoting == .enclosed ? name.replacingOccurrences(of: quote, with: quote + quote) : quoted(name) + return item.recased(label: name, insertText: text) + } + // A qualified reference replaces the user's quotes, so it is spelled as if none were typed. + guard quoting != .enclosed || item.isQualifiedReference else { return item } + + var labels: [String] = [] + var texts: [String] = [] + for part in identifier.parts { + switch part { + case .catalog(let name): + switch form(of: name, typedPrefix: typedPrefix) { + case .bare(let text): + labels.append(text) + texts.append(text) + case .quoted: + labels.append(name) + texts.append(quoted(name)) + } + case .statement(let text, let isQuoted): + labels.append(text) + texts.append(isQuoted ? quoted(text) : text) + } + } + return item.recased(label: labels.joined(separator: "."), insertText: texts.joined(separator: ".")) + } + + private func canStayBare(_ name: String) -> Bool { + let scalars = name.unicodeScalars + guard let first = scalars.first, Self.isASCIILetter(first), + scalars.allSatisfy({ Self.isASCIILetter($0) || Self.isDigit($0) || $0 == "_" }) else { return false } + let folded = rules.folding == .upper ? name.uppercased() : name.lowercased() + return name == folded && !rules.reservedWords.contains(name.uppercased()) + } + + 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/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLIdentifierSpellingTests.swift b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLIdentifierSpellingTests.swift new file mode 100644 index 0000000000..ae5ba04e6f --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProAutocompleteTests/SQLIdentifierSpellingTests.swift @@ -0,0 +1,210 @@ +import Foundation +@testable import TableProAutocomplete +import TableProSQLGrammar +import Testing + +private struct UppercaseCatalog: SQLCompletionCatalog { + let columns: [String: [String]] + + func resolveAlias(_ aliasOrName: String, in references: [TableReference]) -> String? { + let name = aliasOrName.lowercased() + return references.first { $0.alias?.lowercased() == name || $0.tableName.lowercased() == name }?.tableName + } + + func columnCompletionItems(for tableName: String, schema: String?) -> [SQLCompletionItem] { + (columns[tableName.uppercased()] ?? []).map { + SQLCompletionItem.column($0, dataType: nil, tableName: tableName, identifier: .catalog($0)) + } + } + + func tableCompletionItems() -> [SQLCompletionItem] { [] } + 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] { [] } + func allColumnsFromCachedTables() -> [SQLCompletionItem] { [] } +} + +struct SQLIdentifierSpellingTests { + private let upperFolding = SQLIdentifierRules(folding: .upper, reservedWords: ["ORDER", "SELECT"]) + private let lowerFolding = SQLIdentifierRules(folding: .lower, reservedWords: ["user"]) + + private func spelling(_ policy: SQLIdentifierCase, rules: SQLIdentifierRules? = nil) -> SQLIdentifierSpelling { + SQLIdentifierSpelling(rules: rules ?? upperFolding, policy: policy) + } + + private func applied( + _ items: [SQLCompletionItem], + _ policy: SQLIdentifierCase, + prefix: String = "", + quoting: SQLIdentifierTokenQuoting = .none + ) -> [SQLCompletionItem] { + spelling(policy).applied(to: items, typedPrefix: prefix, quoting: quoting) + } + + // MARK: - Which names may change case + + @Test("A name the engine folds back to itself takes the chosen case") + func foldedNameIsRecased() { + #expect(spelling(.lower).form(of: "MY_TABLE") == .bare("my_table")) + #expect(spelling(.upper).form(of: "MY_TABLE") == .bare("MY_TABLE")) + #expect(spelling(.lower, rules: lowerFolding).form(of: "orders") == .bare("orders")) + #expect(spelling(.upper, rules: lowerFolding).form(of: "orders") == .bare("ORDERS")) + } + + @Test("A name stored in mixed case or in the other case is quoted as stored") + func unfoldedNameIsQuoted() { + #expect(spelling(.lower).form(of: "MixedCase") == .quoted) + #expect(spelling(.lower).form(of: "lower_only") == .quoted) + #expect(spelling(.upper, rules: lowerFolding).form(of: "Users") == .quoted) + } + + @Test("A reserved word, a leading digit or underscore, or a non-ASCII name stays quoted") + func unsafeNamesAreQuoted() { + #expect(spelling(.lower).form(of: "ORDER") == .quoted) + #expect(spelling(.lower, rules: lowerFolding).form(of: "user") == .quoted) + #expect(spelling(.lower).form(of: "_PRIVATE") == .quoted) + #expect(spelling(.lower).form(of: "1ST") == .quoted) + #expect(spelling(.lower).form(of: "CAFÉ") == .quoted) + #expect(spelling(.lower).form(of: "MY TABLE") == .quoted) + #expect(spelling(.lower).form(of: "") == .quoted) + } + + @Test("As stored keeps the spelling and quotes only a name that cannot be bare") + func asStoredKeepsSpelling() { + #expect(spelling(.asStored).form(of: "MY_TABLE") == .bare("MY_TABLE")) + #expect(spelling(.asStored).form(of: "MixedCase") == .quoted) + } + + @Test("Matching the typing follows the first cased character, and as stored with none") + func matchTypedFollowsPrefix() { + #expect(spelling(.matchTyped).form(of: "MY_TABLE", typedPrefix: "my_t") == .bare("my_table")) + #expect(spelling(.matchTyped).form(of: "MY_TABLE", typedPrefix: "My") == .bare("MY_TABLE")) + #expect(spelling(.matchTyped).form(of: "MY_TABLE", typedPrefix: "_1") == .bare("MY_TABLE")) + #expect(spelling(.matchTyped).form(of: "MY_TABLE") == .bare("MY_TABLE")) + } + + @Test("A quote inside a quoted name is doubled") + func quoteIsEscaped() { + #expect(spelling(.lower).quoted("a\"b") == "\"a\"\"b\"") + } + + // MARK: - Completion items + + @Test("A table completes in the typed case, and a mixed-case one quoted as stored") + func tableItems() { + let items = applied( + [SQLCompletionItem.table("MY_TABLE"), SQLCompletionItem.table("MixedCase")], + .matchTyped, + prefix: "my" + ) + #expect(items[0].label == "my_table") + #expect(items[0].insertText == "my_table") + #expect(items[1].label == "MixedCase") + #expect(items[1].insertText == "\"MixedCase\"") + } + + @Test("A qualified column keeps the qualifier as written and spells the column") + func qualifiedColumn() { + let item = SQLCompletionItem( + label: "o.TOTAL", + kind: .column, + identifier: SQLCompletionIdentifier([.statement("o", quoted: false), .catalog("TOTAL")]) + ) + let spelled = applied([item], .lower, prefix: "to")[0] + #expect(spelled.label == "o.total") + #expect(spelled.insertText == "o.total") + } + + @Test("A qualifier the statement quoted stays quoted") + func quotedQualifier() { + let item = SQLCompletionItem( + label: "Orders.TOTAL", + kind: .column, + identifier: SQLCompletionIdentifier([.statement("Orders", quoted: true), .catalog("TOTAL")]) + ) + #expect(applied([item], .asStored)[0].insertText == "\"Orders\".TOTAL") + } + + @Test("Inside a closed quote the stored name goes in raw; after an open one it is quoted whole") + func typedQuote() { + let enclosed = applied([SQLCompletionItem.table("MY_TABLE")], .lower, quoting: .enclosed)[0] + #expect(enclosed.insertText == "MY_TABLE") + let opened = applied([SQLCompletionItem.table("MY_TABLE")], .lower, quoting: .opened)[0] + #expect(opened.insertText == "\"MY_TABLE\"") + } + + @Test("A qualified column inside a closed quote is spelled, since it replaces the quotes") + func qualifiedColumnInsideClosedQuote() { + var item = SQLCompletionItem( + label: "o.MixedCase", + kind: .column, + identifier: SQLCompletionIdentifier([.statement("o", quoted: false), .catalog("MixedCase")]) + ) + item.isQualifiedReference = true + #expect(applied([item], .lower, quoting: .enclosed)[0].insertText == "o.\"MixedCase\"") + } + + @Test("Keywords and items without a catalog name are untouched") + func otherItemsUntouched() { + let keyword = SQLCompletionItem.keyword("SELECT") + let literal = SQLCompletionItem(label: "'Active'", kind: .keyword) + let items = applied([keyword, literal], .lower, prefix: "s") + #expect(items[0].insertText == "SELECT") + #expect(items[1].insertText == "'Active'") + } + + @Test("A re-cased item keeps its match ranges and filter text") + func matchStateSurvives() { + var item = SQLCompletionItem.table("MY_TABLE") + item.matchedRanges = [0..<4] + let spelled = applied([item], .lower, prefix: "my_t")[0] + #expect(spelled.matchedRanges == [0..<4]) + #expect(spelled.filterText == "my_table") + } + + // MARK: - Statement names + + @Test("A statement name is re-cased only for an explicit case, else quoted as the caller does") + func statementNames() { + let quote: (String) -> String = { "\"\($0)\"" } + #expect(spelling(.lower).statementName("MY_TABLE", quotedBy: quote) == "my_table") + #expect(spelling(.lower).statementName("MixedCase", quotedBy: quote) == "\"MixedCase\"") + #expect(spelling(.asStored).statementName("MY_TABLE", quotedBy: quote) == "\"MY_TABLE\"") + #expect(spelling(.matchTyped).statementName("MY_TABLE", quotedBy: quote) == "\"MY_TABLE\"") + } + + @Test("The token quoting is read from the analyzer's quoted name") + func tokenQuoting() { + #expect(SQLIdentifierTokenQuoting(nil) == .none) + #expect(SQLIdentifierTokenQuoting(SQLQuotedName(open: 3, close: nil)) == .opened) + #expect(SQLIdentifierTokenQuoting(SQLQuotedName(open: 3, close: 8)) == .enclosed) + } + + // MARK: - Through the provider + + private let provider = SQLCompletionProvider( + catalog: UppercaseCatalog(columns: ["ORDERS": ["TOTAL"], "CUSTOMERS": ["NAME"]]), + grammar: .ansi, + vocabulary: .generic + ) + + @Test("A join condition's qualified column keeps the alias and spells the column") + func joinConditionColumn() async { + let text = "SELECT * FROM ORDERS o JOIN CUSTOMERS c ON tot" + let session = await provider.completionSession(text: text, cursorPosition: (text as NSString).length) + let spelled = applied(session.items, .lower, prefix: "tot") + #expect(spelled.contains { $0.insertText == "o.total" }) + } + + @Test("All columns of a quoted reference keep its quotes") + func quotedReferenceStar() async { + let text = "SELECT FROM \"Orders\"" + let session = await provider.completionSession(text: text, cursorPosition: 7) + let spelled = applied(session.items, .asStored) + #expect(spelled.contains { $0.insertText == "\"Orders\".*" }) + } +} diff --git a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift index b68b83b247..f061d8a598 100644 --- a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -12,6 +12,7 @@ struct CompletionContext { final class CompletionEngine { private let provider: SQLCompletionProvider + private let identifierRules: SQLIdentifierRules? init( schemaProvider: SQLSchemaProvider?, @@ -25,23 +26,43 @@ final class CompletionEngine { dialect: dialect, statementCompletions: statementCompletions ) + self.identifierRules = databaseType?.identifierRules } func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { provider.updateFavoriteKeywords(keywords) } - // Every SQL completion reaches a UI surface through here, so the case policy is applied once. + // Every SQL completion item passes through the engine on its way to a surface, so both case policies apply here. func rank( _ items: [SQLCompletionItem], prefix: String, context: SQLContext, - keywordCase: SQLKeywordCase + keywordCase: SQLKeywordCase, + identifierCase: SQLIdentifierCase = .default ) -> [SQLCompletionItem] { - SQLCompletionCasing.applied( - to: provider.filterRankAndLimit(items, prefix: prefix, context: context), + spelled( + provider.filterRankAndLimit(items, prefix: prefix, context: context), typedPrefix: prefix, - policy: keywordCase + context: context, + keywordCase: keywordCase, + identifierCase: identifierCase + ) + } + + private func spelled( + _ items: [SQLCompletionItem], + typedPrefix: String, + context: SQLContext, + keywordCase: SQLKeywordCase, + identifierCase: SQLIdentifierCase + ) -> [SQLCompletionItem] { + let cased = SQLCompletionCasing.applied(to: items, typedPrefix: typedPrefix, policy: keywordCase) + guard let identifierRules else { return cased } + return SQLIdentifierSpelling(rules: identifierRules, policy: identifierCase).applied( + to: cased, + typedPrefix: typedPrefix, + quoting: SQLIdentifierTokenQuoting(context.quotedName) ) } @@ -50,7 +71,8 @@ final class CompletionEngine { fragment: String, cursorPosition: Int, tableName: String, - keywordCase: SQLKeywordCase = .default + keywordCase: SQLKeywordCase = .default, + identifierCase: SQLIdentifierCase = .default ) async -> CompletionContext? { let clausePrefix = "WHERE " let prefixLength = (clausePrefix as NSString).length @@ -60,6 +82,7 @@ final class CompletionEngine { text: clausePrefix + fragment, cursorPosition: cursorPosition + prefixLength, keywordCase: keywordCase, + identifierCase: identifierCase, forcedTableReferences: references ), context.replacementRange.location >= prefixLength else { return nil @@ -79,6 +102,7 @@ final class CompletionEngine { text: String, cursorPosition: Int, keywordCase: SQLKeywordCase = .default, + identifierCase: SQLIdentifierCase = .default, forcedTableReferences: [TableReference]? = nil ) async -> CompletionContext? { let (items, candidates, context) = await provider.completionSession( @@ -89,7 +113,13 @@ final class CompletionEngine { guard !items.isEmpty else { return nil } return CompletionContext( - items: SQLCompletionCasing.applied(to: items, typedPrefix: context.prefix, policy: keywordCase), + items: spelled( + items, + typedPrefix: context.prefix, + context: context, + keywordCase: keywordCase, + identifierCase: identifierCase + ), candidates: candidates, replacementRange: NSRange(context.prefixRange), sqlContext: context diff --git a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift index 7cf8d9fa80..29433b95cd 100644 --- a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift @@ -26,7 +26,8 @@ enum QueryCompletionServiceFactory { static func make( schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, - profile: QueryCompletionProfile? = nil + profile: QueryCompletionProfile? = nil, + connectionId: UUID? = nil ) -> QueryCompletionService { let language = databaseType.map { PluginManager.shared.editorLanguage(for: $0) } ?? .sql @@ -37,7 +38,8 @@ enum QueryCompletionServiceFactory { return SQLCompletionService( schemaProvider: schemaProvider, databaseType: databaseType, - profile: profile + profile: profile, + identifierCase: { SQLIdentifierCase.stored(for: connectionId) } ) } } diff --git a/apple/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift b/apple/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift index 445c594c86..d332cc076e 100644 --- a/apple/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift +++ b/apple/TablePro/Core/Autocomplete/RawSQLFilterCompletionProvider.swift @@ -25,12 +25,14 @@ struct RawSQLFilterCompletions { final class RawSQLFilterCompletionProvider { private let engine: CompletionEngine private let tableName: String + private let connectionId: UUID? init( schemaProvider: SQLSchemaProvider, databaseType: DatabaseType, tableName: String, - profile: QueryCompletionProfile? = nil + profile: QueryCompletionProfile? = nil, + connectionId: UUID? = nil ) { let dialect = profile?.resolvedDialect ?? PluginManager.shared.sqlDialect(for: databaseType) let statementCompletions = profile?.statementCompletions @@ -42,6 +44,7 @@ final class RawSQLFilterCompletionProvider { statementCompletions: statementCompletions ) self.tableName = tableName + self.connectionId = connectionId } func completions(fieldText: String, cursor: Int) async -> RawSQLFilterCompletions? { @@ -49,7 +52,8 @@ final class RawSQLFilterCompletionProvider { fragment: fieldText, cursorPosition: cursor, tableName: tableName, - keywordCase: AppSettingsManager.shared.editor.keywordCase + keywordCase: AppSettingsManager.shared.editor.keywordCase, + identifierCase: SQLIdentifierCase.stored(for: connectionId) ) else { return nil } diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift index 4b0e668ae6..c00e40c4b3 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -7,12 +7,14 @@ import TableProSQLGrammar final class SQLCompletionService: QueryCompletionService { private let engine: CompletionEngine private let grammar: SQLLexicalGrammar + private let identifierCaseSource: @MainActor () -> SQLIdentifierCase private var lastContext = SQLContext.unanalyzed init( schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, - profile: QueryCompletionProfile? = nil + profile: QueryCompletionProfile? = nil, + identifierCase: @escaping @MainActor () -> SQLIdentifierCase = { .default } ) { let dialect = profile?.resolvedDialect ?? databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } @@ -26,12 +28,14 @@ final class SQLCompletionService: QueryCompletionService { statementCompletions: statementCompletions ) self.grammar = databaseType?.lexicalGrammar ?? .ansi + self.identifierCaseSource = identifierCase } var triggerCharacters: Set { [".", " ", ":", "(", ","] } // 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 } + private var identifierCase: SQLIdentifierCase { identifierCaseSource() } func updateFavoriteKeywords(_ keywords: [String: (name: String, query: String)]) { engine.updateFavoriteKeywords(keywords) @@ -51,7 +55,8 @@ final class SQLCompletionService: QueryCompletionService { items, prefix: matchText, context: lastContext, - keywordCase: keywordCase + keywordCase: keywordCase, + identifierCase: identifierCase ) } @@ -61,7 +66,8 @@ final class SQLCompletionService: QueryCompletionService { let result = await engine.getCompletions( text: window.text, cursorPosition: offset - window.start, - keywordCase: keywordCase + keywordCase: keywordCase, + identifierCase: identifierCase ) else { return nil } diff --git a/apple/TablePro/Core/Autocomplete/SQLIdentifierCase+Connection.swift b/apple/TablePro/Core/Autocomplete/SQLIdentifierCase+Connection.swift new file mode 100644 index 0000000000..bb9aaa63aa --- /dev/null +++ b/apple/TablePro/Core/Autocomplete/SQLIdentifierCase+Connection.swift @@ -0,0 +1,11 @@ +import Foundation +import TableProAutocomplete + +extension SQLIdentifierCase { + // Read per request, so a changed setting applies to the next keystroke rather than the next connect. + @MainActor + static func stored(for connectionId: UUID?) -> SQLIdentifierCase { + guard let connectionId else { return .default } + return ConnectionStorage.shared.loadConnection(id: connectionId)?.completionIdentifierCase ?? .default + } +} diff --git a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 67dfec850b..5b63da0a12 100644 --- a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -210,6 +210,13 @@ actor SQLSchemaProvider { return "\(schema).\(canonicalName)" } + // The cache key holds the names lowercased, so only a table the list names keeps its stored spelling. + private func fallbackTableParts(for key: ColumnCacheKey, storedName: String?) -> [SQLCompletionIdentifier.Part] { + let table: SQLCompletionIdentifier.Part = storedName.map { .catalog($0) } ?? .statement(key.name, quoted: false) + guard let schema = key.schema, schema != getDefaultSchema()?.lowercased() else { return [table] } + return [.statement(schema, quoted: false), table] + } + /// The schema a table can be named in without its schema: the engine's implicit schema when it /// has one, otherwise the schema the driver was on when this scope loaded. func getDefaultSchema() -> String? { @@ -570,7 +577,8 @@ actor SQLSchemaProvider { SQLCompletionItem.column( $0.name, dataType: $0.type, tableName: tableName, isPrimaryKey: $0.isPK, isNullable: $0.isNullable, - defaultValue: $0.defaultValue, comment: $0.comment + defaultValue: $0.defaultValue, comment: $0.comment, + identifier: .catalog($0.name) ) } } @@ -625,12 +633,14 @@ actor SQLSchemaProvider { // swiftlint:disable:next large_tuple var itemDataBuilder: [( label: String, insertText: String, type: String?, table: String, - isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String?, isQualified: Bool + isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String?, + identifier: SQLCompletionIdentifier?, isQualified: Bool )] = [] let hasMultipleRefs = references.count > 1 for ref in references { let refId = ref.identifier + let qualifier = SQLCompletionProvider.qualifierPart(for: ref) if let derivedColumns = ref.derivedColumns { for name in derivedColumns { let label = hasMultipleRefs ? "\(refId).\(name)" : name @@ -638,7 +648,11 @@ actor SQLSchemaProvider { ( label: label, insertText: label, type: nil, table: refId, isPK: false, isNullable: true, - defaultValue: nil, comment: nil, isQualified: hasMultipleRefs + defaultValue: nil, comment: nil, + identifier: hasMultipleRefs + ? SQLCompletionIdentifier([qualifier, .statement(name, quoted: false)]) + : nil, + isQualified: hasMultipleRefs )) } continue @@ -653,7 +667,11 @@ actor SQLSchemaProvider { label: label, insertText: insertText, type: column.dataType, table: ref.tableName, isPK: column.isPrimaryKey, isNullable: column.isNullable, defaultValue: column.defaultValue, - comment: column.comment, isQualified: hasMultipleRefs + comment: column.comment, + identifier: SQLCompletionIdentifier( + hasMultipleRefs ? [qualifier, .catalog(column.name)] : [.catalog(column.name)] + ), + isQualified: hasMultipleRefs )) } } @@ -666,7 +684,8 @@ actor SQLSchemaProvider { var item = SQLCompletionItem.column( $0.label, dataType: $0.type, tableName: $0.table, isPrimaryKey: $0.isPK, isNullable: $0.isNullable, - defaultValue: $0.defaultValue, comment: $0.comment + defaultValue: $0.defaultValue, comment: $0.comment, + identifier: $0.identifier ) item.isQualifiedReference = $0.isQualified return item @@ -684,8 +703,13 @@ actor SQLSchemaProvider { tables.map { ($0.name.lowercased(), $0.name) }, uniquingKeysWith: { first, _ in first } ) + // Keyed by schema too: a table's spelling in one schema says nothing about a namesake in another. + let storedNames = Dictionary( + tables.map { (cacheKey(table: $0.name, schema: $0.schema), $0.name) }, + uniquingKeysWith: { first, _ in first } + ) - var allEntries: [(table: String, col: ColumnInfo)] = [] + var allEntries: [(table: String, tableParts: [SQLCompletionIdentifier.Part], col: ColumnInfo)] = [] var nameCount: [String: Int] = [:] // Sorted, because this emission order is what `rankResults` falls back to for candidates @@ -696,8 +720,9 @@ actor SQLSchemaProvider { for key in columnCache.keys.sorted(by: Self.isOrderedBefore) { guard let columns = columnCache[key] else { continue } let tableName = fallbackTableLabel(for: key, canonicalName: canonicalNames[key.name] ?? key.name) + let tableParts = fallbackTableParts(for: key, storedName: storedNames[key]) for col in columns { - allEntries.append((table: tableName, col: col)) + allEntries.append((table: tableName, tableParts: tableParts, col: col)) nameCount[col.name.lowercased(), default: 0] += 1 } } @@ -705,7 +730,8 @@ actor SQLSchemaProvider { // swiftlint:disable:next large_tuple var itemDataBuilder: [( label: String, insertText: String, type: String, table: String, - isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String?, isQualified: Bool + isPK: Bool, isNullable: Bool, defaultValue: String?, comment: String?, + identifier: SQLCompletionIdentifier, isQualified: Bool )] = [] for entry in allEntries { @@ -717,7 +743,11 @@ actor SQLSchemaProvider { label: label, insertText: insertText, type: entry.col.dataType, table: entry.table, isPK: entry.col.isPrimaryKey, isNullable: entry.col.isNullable, defaultValue: entry.col.defaultValue, - comment: entry.col.comment, isQualified: isAmbiguous + comment: entry.col.comment, + identifier: SQLCompletionIdentifier( + isAmbiguous ? entry.tableParts + [.catalog(entry.col.name)] : [.catalog(entry.col.name)] + ), + isQualified: isAmbiguous )) } @@ -728,7 +758,8 @@ actor SQLSchemaProvider { var item = SQLCompletionItem.column( $0.label, dataType: $0.type, tableName: $0.table, isPrimaryKey: $0.isPK, isNullable: $0.isNullable, - defaultValue: $0.defaultValue, comment: $0.comment + defaultValue: $0.defaultValue, comment: $0.comment, + identifier: $0.identifier ) item.sortPriority = 150 item.isQualifiedReference = $0.isQualified diff --git a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift index 58b43da544..78e5dd8cce 100644 --- a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift +++ b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift @@ -6,6 +6,7 @@ import AppKit import Foundation import SwiftUI +import TableProAutocomplete import TableProPluginKit /// Everything a `DatabaseType` knows by asking the plugin registry. @@ -146,6 +147,10 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(for: self)?.schema.implicitSchemaName } + var identifierRules: SQLIdentifierRules? { + PluginMetadataRegistry.shared.snapshot(for: self)?.schema.identifierRules + } + var supportsCloudSQLProxy: Bool { switch rawValue { case "MySQL", "PostgreSQL", "SQL Server": diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 547e2a392b..980fbea84a 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -492,7 +492,8 @@ extension PluginMetadataRegistry { fileExtensions: [], databaseGroupingStrategy: .hierarchicalSchema, structureColumnFields: [.name, .type, .nullable, .defaultValue, .comment], - systemDatabaseNamesIgnoreCase: true + systemDatabaseNamesIgnoreCase: true, + identifierRules: .snowflake ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: SQLDialectDescriptor( diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index b00ca433ce..7bc428eeba 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -411,7 +411,8 @@ extension PluginMetadataRegistry { structureColumnFields: [ .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .autoIncrement, .comment - ] + ], + identifierRules: .postgreSQL ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: postgresqlDialect, @@ -618,7 +619,8 @@ extension PluginMetadataRegistry { structureColumnFields: [ .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .autoIncrement, .comment - ] + ], + identifierRules: .postgreSQL ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: postgresqlDialect, diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+IdentifierRules.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+IdentifierRules.swift new file mode 100644 index 0000000000..92dc13b4f3 --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+IdentifierRules.swift @@ -0,0 +1,51 @@ +import Foundation +import TableProAutocomplete + +// A reserved word, or a name a bare spelling reads as a built-in value, stays quoted even in the folded case. +extension SQLIdentifierRules { + // PostgreSQL 17 reference, Appendix C: the reserved and "reserved (can be function or type)" key words. + static let postgreSQL = SQLIdentifierRules(folding: .lower, reservedWords: [ + "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", + ]) + + // Oracle Database SQL Language Reference: Oracle SQL Reserved Words, then the pseudocolumns and the functions + // called without parentheses, which a bare column of that name reads as instead. + static let oracle = SQLIdentifierRules(folding: .upper, reservedWords: [ + "ACCESS", "ADD", "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "AUDIT", "BETWEEN", "BY", "CHAR", "CHECK", + "CLUSTER", "COLUMN", "COLUMN_VALUE", "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", "NESTED_TABLE_ID", "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", + "CONNECT_BY_ISCYCLE", "CONNECT_BY_ISLEAF", "CURRVAL", "NEXTVAL", "OBJECT_ID", "OBJECT_VALUE", "ORA_ROWSCN", + "XMLDATA", "CURRENT_DATE", "CURRENT_TIMESTAMP", "DBTIMEZONE", "LOCALTIMESTAMP", "SESSIONTIMEZONE", + "SYSTIMESTAMP", + ]) + + // Snowflake SQL reference, Reserved & limited keywords. + static let snowflake = SQLIdentifierRules(folding: .upper, reservedWords: [ + "ACCOUNT", "ALL", "ALTER", "AND", "ANY", "AS", "BETWEEN", "BY", "CASE", "CAST", "CHECK", "COLUMN", "CONNECT", + "CONNECTION", "CONSTRAINT", "CREATE", "CROSS", "CURRENT", "CURRENT_DATE", "CURRENT_TIME", + "CURRENT_TIMESTAMP", "CURRENT_USER", "DATABASE", "DELETE", "DISTINCT", "DROP", "ELSE", "EXISTS", "FALSE", + "FOLLOWING", "FOR", "FROM", "FULL", "GRANT", "GROUP", "GSCLUSTER", "HAVING", "ILIKE", "IN", "INCREMENT", + "INNER", "INSERT", "INTERSECT", "INTO", "IS", "ISSUE", "JOIN", "LATERAL", "LEFT", "LIKE", "LOCALTIME", + "LOCALTIMESTAMP", "MINUS", "NATURAL", "NOT", "NULL", "OF", "ON", "OR", "ORDER", "ORGANIZATION", "QUALIFY", + "REGEXP", "REVOKE", "RIGHT", "RLIKE", "ROW", "ROWS", "SAMPLE", "SCHEMA", "SELECT", "SET", "SOME", "START", + "TABLE", "TABLESAMPLE", "THEN", "TO", "TRIGGER", "TRUE", "TRY_CAST", "UNION", "UNIQUE", "UPDATE", "USING", + "VALUES", "VIEW", "WHEN", "WHENEVER", "WHERE", "WITH", + ]) +} diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index a9c7ddbaae..e39b1e25e1 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -464,7 +464,8 @@ extension PluginMetadataRegistry { fileExtensions: [], databaseGroupingStrategy: .hierarchicalSchema, structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment], - rowMatchExcludedTypePrefixes: Self.oracleRowMatchExcludedTypePrefixes + rowMatchExcludedTypePrefixes: Self.oracleRowMatchExcludedTypePrefixes, + identifierRules: .oracle ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: oracleDialect, diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 0e1a62bf46..d439252ffd 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -1,5 +1,6 @@ import Foundation import os +import TableProAutocomplete import TableProPluginKit import TableProUtilities @@ -195,6 +196,8 @@ struct PluginMetadataSnapshot: Sendable { /// Teradata and Snowflake compare database names without regard to case, so `dbc` is the /// system database `DBC`. Every other engine keeps case: MySQL can hold a user database `MySQL`. let systemDatabaseNamesIgnoreCase: Bool + // Nil for an engine that keeps the case of an unquoted name, or whose rule is not curated yet. + let identifierRules: SQLIdentifierRules? init( defaultSchemaName: String, @@ -213,7 +216,8 @@ struct PluginMetadataSnapshot: Sendable { implicitSchemaName: String? = nil, rowMatchExcludedTypePrefixes: [String] = [], rowMatchTextTypePrefixes: [String] = [], - systemDatabaseNamesIgnoreCase: Bool = false + systemDatabaseNamesIgnoreCase: Bool = false, + identifierRules: SQLIdentifierRules? = nil ) { self.defaultSchemaName = defaultSchemaName self.defaultGroupName = defaultGroupName @@ -232,6 +236,7 @@ struct PluginMetadataSnapshot: Sendable { self.rowMatchExcludedTypePrefixes = rowMatchExcludedTypePrefixes self.rowMatchTextTypePrefixes = rowMatchTextTypePrefixes self.systemDatabaseNamesIgnoreCase = systemDatabaseNamesIgnoreCase + self.identifierRules = identifierRules } static let defaults = SchemaInfo( @@ -372,7 +377,8 @@ struct PluginMetadataSnapshot: Sendable { implicitSchemaName: schema.implicitSchemaName, rowMatchExcludedTypePrefixes: schema.rowMatchExcludedTypePrefixes, rowMatchTextTypePrefixes: schema.rowMatchTextTypePrefixes, - systemDatabaseNamesIgnoreCase: schema.systemDatabaseNamesIgnoreCase + systemDatabaseNamesIgnoreCase: schema.systemDatabaseNamesIgnoreCase, + identifierRules: schema.identifierRules ), editor: editor, connection: connection @@ -444,7 +450,8 @@ struct PluginMetadataSnapshot: Sendable { implicitSchemaName: source.schema.implicitSchemaName, rowMatchExcludedTypePrefixes: schema.rowMatchExcludedTypePrefixes, rowMatchTextTypePrefixes: schema.rowMatchTextTypePrefixes, - systemDatabaseNamesIgnoreCase: schema.systemDatabaseNamesIgnoreCase + systemDatabaseNamesIgnoreCase: schema.systemDatabaseNamesIgnoreCase, + identifierRules: schema.identifierRules ), editor: editor, connection: connection ) @@ -788,7 +795,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { implicitSchemaName: existingSnapshot?.schema.implicitSchemaName, rowMatchExcludedTypePrefixes: existingSnapshot?.schema.rowMatchExcludedTypePrefixes ?? [], rowMatchTextTypePrefixes: existingSnapshot?.schema.rowMatchTextTypePrefixes ?? [], - systemDatabaseNamesIgnoreCase: existingSnapshot?.schema.systemDatabaseNamesIgnoreCase ?? false + systemDatabaseNamesIgnoreCase: existingSnapshot?.schema.systemDatabaseNamesIgnoreCase ?? false, + identifierRules: existingSnapshot?.schema.identifierRules ), editor: PluginMetadataSnapshot.EditorConfig( sqlDialect: driverType.sqlDialect, diff --git a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index 428ce3aaeb..f67c628c10 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -3,6 +3,7 @@ // TablePro import Foundation +import TableProAutocomplete import TableProPluginKit /// Writes grid rows as INSERT and UPDATE text for the user to run, with the values written in. @@ -62,7 +63,8 @@ internal struct SQLRowToStatementConverter { settableColumns: [String]? = nil, dialect: SQLDialectDescriptor? = nil, quoteIdentifier: ((String) -> String)? = nil, - escapeStringLiteral: ((String) -> String)? = nil + escapeStringLiteral: ((String) -> String)? = nil, + identifierSpelling: SQLIdentifierSpelling? = nil ) throws { self.tableName = tableName self.schemaName = schemaName @@ -73,15 +75,20 @@ internal struct SQLRowToStatementConverter { self.rowMatchPolicy = rowMatchPolicy self.settableColumns = settableColumns + let quote: (String) -> String if let quoteIdentifier, let escapeStringLiteral { - self.quoteIdentifierFn = quoteIdentifier + quote = quoteIdentifier self.escapeStringFn = escapeStringLiteral - return + } else { + let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect) + quote = quoteIdentifier ?? quoteIdentifierFromDialect(resolvedDialect) + self.escapeStringFn = escapeStringLiteral ?? escapeStringLiteralFromDialect(resolvedDialect) + } + if let identifierSpelling { + self.quoteIdentifierFn = { identifierSpelling.statementName($0, quotedBy: quote) } + } else { + self.quoteIdentifierFn = quote } - - let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect) - self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(resolvedDialect) - self.escapeStringFn = escapeStringLiteral ?? escapeStringLiteralFromDialect(resolvedDialect) } private var qualifiedTable: String { diff --git a/apple/TablePro/Models/Connection/DatabaseConnection.swift b/apple/TablePro/Models/Connection/DatabaseConnection.swift index a4ad814367..fd4c98fc51 100644 --- a/apple/TablePro/Models/Connection/DatabaseConnection.swift +++ b/apple/TablePro/Models/Connection/DatabaseConnection.swift @@ -8,6 +8,7 @@ import AppKit import Foundation import SwiftUI +import TableProAutocomplete import TableProPluginKit import TableProSSHConfig import TableProSSHTransport @@ -154,6 +155,7 @@ struct DatabaseConnection: Identifiable, Hashable { static let queryTimeoutSecondsRange = 0 ... Int(Int32.max) / 1_000 static let postDisconnectScriptKey = "postDisconnectScript" static let scriptTimeoutSecondsKey = "scriptTimeoutSeconds" + static let completionIdentifierCaseKey = "completionIdentifierCase" let id: UUID var name: String @@ -316,6 +318,19 @@ struct DatabaseConnection: Identifiable, Hashable { } } + var completionIdentifierCase: SQLIdentifierCase { + get { + additionalFields[Self.completionIdentifierCaseKey].flatMap(SQLIdentifierCase.init(rawValue:)) ?? .default + } + set { + if newValue == .default { + additionalFields.removeValue(forKey: Self.completionIdentifierCaseKey) + } else { + additionalFields[Self.completionIdentifierCaseKey] = newValue.rawValue + } + } + } + var sshForwardUnixSocketPath: String? { get { additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey]?.nilIfEmpty } set { additionalFields[DatabaseConnection.sshForwardUnixSocketPathKey] = newValue ?? "" } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index a24f0918ed..e6c9466897 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -26984,6 +26984,9 @@ } } } + }, + "As stored" : { + }, "Ascending" : { "localizations" : { @@ -28819,6 +28822,9 @@ } } } + }, + "Autocomplete" : { + }, "Autocomplete from your schema, editor tabs, and a searchable query history." : { "localizations" : { @@ -105297,6 +105303,9 @@ } } } + }, + "Match what I type" : { + }, "Match what I type, otherwise UPPERCASE" : { "extractionState" : "manual", @@ -176558,6 +176567,9 @@ } } } + }, + "Table, column and schema names take this case in autocomplete and in Copy as INSERT and UPDATE when the server reads the new spelling as the same name. Other names are quoted as stored." : { + }, "Table-level CHECK constraints" : { "localizations" : { diff --git a/apple/TablePro/Views/Compare/CompareTableScopeEditor.swift b/apple/TablePro/Views/Compare/CompareTableScopeEditor.swift index fa2a55c5f1..3ac9ef08fb 100644 --- a/apple/TablePro/Views/Compare/CompareTableScopeEditor.swift +++ b/apple/TablePro/Views/Compare/CompareTableScopeEditor.swift @@ -295,7 +295,8 @@ internal struct CompareTableScopeEditor: View { return RawSQLFilterCompletionProvider( schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: endpoint.scope), databaseType: endpoint.databaseType, - tableName: plan.table + tableName: plan.table, + connectionId: endpoint.scope.connectionId ) } } diff --git a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 659f7120fb..25bfd7177a 100644 --- a/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/apple/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -361,6 +361,7 @@ final class ConnectionFormCoordinator: ObservableObject { externalAccess: advanced.externalAccess, connectTimeoutSeconds: customization.connectTimeoutSeconds, queryTimeoutSeconds: customization.queryTimeoutSeconds, + completionIdentifierCase: customization.completionIdentifierCase, redisDatabase: advanced.additionalFieldValues[RedisDatabaseIndex.fieldName].map { RedisDatabaseIndex.parse($0) ?? 0 }, diff --git a/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index a139a64c25..0bd1a5deae 100644 --- a/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import TableProAutocomplete import TableProPluginKit /// What TablePro is allowed to do with this connection, and what it runs when it opens it. @@ -24,6 +25,7 @@ struct OptionsPaneView: View { driverSection extensionsSection timeoutSection + completionSection startupSection preConnectSection postDisconnectSection @@ -132,6 +134,44 @@ struct OptionsPaneView: View { ) } + // MARK: - Autocomplete + + @ViewBuilder + private var completionSection: some View { + if databaseType.identifierRules != nil { + Section { + Picker( + String(localized: "Identifier case"), + selection: $coordinator.customization.completionIdentifierCase + ) { + ForEach(SQLIdentifierCase.allCases, id: \.self) { identifierCase in + Text(Self.title(for: identifierCase)).tag(identifierCase) + } + } + .accessibilityIdentifier("connection-form-identifier-case") + } header: { + Text(String(localized: "Autocomplete")) + } footer: { + Text(String(localized: """ + Table, column and schema names take this case in autocomplete and in Copy as INSERT \ + and UPDATE when the server reads the new spelling as the same name. Other names are \ + quoted as stored. + """)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private static func title(for identifierCase: SQLIdentifierCase) -> String { + switch identifierCase { + case .asStored: return String(localized: "As stored") + case .lower: return String(localized: "lowercase") + case .upper: return String(localized: "UPPERCASE") + case .matchTyped: return String(localized: "Match what I type") + } + } + // MARK: - Driver options @ViewBuilder diff --git a/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift b/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift index 51a9efd969..2e69056a34 100644 --- a/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift +++ b/apple/TablePro/Views/ConnectionForm/Support/ConnectionFormEdits.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProAutocomplete import TableProSSHConfig struct ConnectionFormEdits: Equatable { @@ -32,6 +33,7 @@ struct ConnectionFormEdits: Equatable { var externalAccess: ExternalAccessLevel var connectTimeoutSeconds: Int? var queryTimeoutSeconds: Int? + var completionIdentifierCase: SQLIdentifierCase = .default var redisDatabase: Int? var startupCommands: String? var localOnly: Bool @@ -45,6 +47,7 @@ struct ConnectionFormEdits: Equatable { "promptForPassword", DatabaseConnection.connectTimeoutSecondsKey, DatabaseConnection.queryTimeoutSecondsKey, + DatabaseConnection.completionIdentifierCaseKey, DatabaseConnection.sshForwardUnixSocketPathKey, MySQLLocalSocket.fieldKey ] @@ -80,6 +83,7 @@ struct ConnectionFormEdits: Equatable { result.additionalFields = mergedAdditionalFields(onto: base.additionalFields) result.connectTimeoutSeconds = connectTimeoutSeconds result.queryTimeoutSeconds = queryTimeoutSeconds + result.completionIdentifierCase = completionIdentifierCase return result } diff --git a/apple/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift b/apple/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift index a5cf0b1a7d..49c43238ca 100644 --- a/apple/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift +++ b/apple/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift @@ -5,6 +5,7 @@ import Combine import Foundation +import TableProAutocomplete import TableProConnectionLibrary @MainActor @@ -16,6 +17,7 @@ final class CustomizationPaneViewModel: ObservableObject { @Published var safeModeLevel: SafeModeLevel = .silent @Published var connectTimeoutSeconds: Int? @Published var queryTimeoutSeconds: Int? + @Published var completionIdentifierCase: SQLIdentifierCase = .default @Published var coordinator: WeakCoordinatorRef? @@ -42,5 +44,6 @@ final class CustomizationPaneViewModel: ObservableObject { safeModeLevel = connection.preferredSafeModeLevel connectTimeoutSeconds = connection.connectTimeoutSeconds queryTimeoutSeconds = connection.queryTimeoutSeconds + completionIdentifierCase = connection.completionIdentifierCase } } diff --git a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift index 96f8fe6f7a..488898c540 100644 --- a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -30,6 +30,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let schemaProvider: ObjectIdentifier? let databaseType: DatabaseType? let profileRevision: String? + let connectionId: UUID? } private var service: QueryCompletionService @@ -48,19 +49,22 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { func configure( schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, - profile: QueryCompletionProfile? = nil + profile: QueryCompletionProfile? = nil, + connectionId: UUID? = nil ) { let requested = Configuration( schemaProvider: schemaProvider.map(ObjectIdentifier.init), databaseType: databaseType, - profileRevision: profile?.revision + profileRevision: profile?.revision, + connectionId: connectionId ) guard requested != configuration else { return } configuration = requested service = QueryCompletionServiceFactory.make( schemaProvider: schemaProvider, databaseType: databaseType, - profile: profile + profile: profile, + connectionId: connectionId ) service.updateFavoriteKeywords(favoriteKeywords) session = nil diff --git a/apple/TablePro/Views/Editor/SQLEditorView.swift b/apple/TablePro/Views/Editor/SQLEditorView.swift index 9c0f01e43d..5529bb47d1 100644 --- a/apple/TablePro/Views/Editor/SQLEditorView.swift +++ b/apple/TablePro/Views/Editor/SQLEditorView.swift @@ -170,7 +170,8 @@ struct SQLEditorView: View { completionAdapter.configure( schemaProvider: schemaProvider, databaseType: databaseType, - profile: completionProfile + profile: completionProfile, + connectionId: connectionId ) } diff --git a/apple/TablePro/Views/Filter/QueryTabFilterPanel.swift b/apple/TablePro/Views/Filter/QueryTabFilterPanel.swift index d21db96da9..47cfd7e917 100644 --- a/apple/TablePro/Views/Filter/QueryTabFilterPanel.swift +++ b/apple/TablePro/Views/Filter/QueryTabFilterPanel.swift @@ -115,7 +115,8 @@ internal struct QueryTabFilterPanel: View { rawSQLCompletionProvider = RawSQLFilterCompletionProvider( schemaProvider: schemaProvider, databaseType: databaseType, - tableName: tableName + tableName: tableName, + connectionId: scope.connectionId ) } } diff --git a/apple/TablePro/Views/Results/DataGridView+RowActions.swift b/apple/TablePro/Views/Results/DataGridView+RowActions.swift index 2f9d9ba207..c3b6ce80cf 100644 --- a/apple/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/apple/TablePro/Views/Results/DataGridView+RowActions.swift @@ -5,6 +5,7 @@ import AppKit import os +import TableProAutocomplete import TableProPluginKit private let rowActionsLogger = Logger(subsystem: "com.TablePro", category: "DataGridView+RowActions") @@ -161,7 +162,8 @@ extension TableViewCoordinator { databaseType: databaseType, unwritableColumns: unwritableColumns(in: tableRows), quoteIdentifier: driver?.quoteIdentifier, - escapeStringLiteral: driver?.escapeStringLiteral + escapeStringLiteral: driver?.escapeStringLiteral, + identifierSpelling: copiedIdentifierSpelling(for: databaseType) ) let rows = indices.sorted().compactMap(displayRow(at:)).map { row in SQLRowToStatementConverter.SourceRow( @@ -194,7 +196,8 @@ extension TableViewCoordinator { rowMatchPolicy: tableRows.rowMatchPolicy, settableColumns: settable.columns(tableRows.columns), quoteIdentifier: driver?.quoteIdentifier, - escapeStringLiteral: driver?.escapeStringLiteral + escapeStringLiteral: driver?.escapeStringLiteral, + identifierSpelling: copiedIdentifierSpelling(for: databaseType) ) let rows = indices.sorted().compactMap { displayRow(at: $0).map(copiedSourceRow) } writeCopiedStatements(converter.generateUpdates(rows: rows)) @@ -203,6 +206,11 @@ extension TableViewCoordinator { } } + private func copiedIdentifierSpelling(for databaseType: DatabaseType) -> SQLIdentifierSpelling? { + guard let rules = databaseType.identifierRules else { return nil } + return SQLIdentifierSpelling(rules: rules, policy: SQLIdentifierCase.stored(for: connectionId)) + } + private func unwritableColumns(in tableRows: TableRows) -> Set { Set(tableRows.columns.filter { tableRows.generatedColumns.contains($0) || !isColumnWritable($0) }) } diff --git a/apple/TableProTests/Core/Autocomplete/CompletionIdentifierCaseTests.swift b/apple/TableProTests/Core/Autocomplete/CompletionIdentifierCaseTests.swift new file mode 100644 index 0000000000..7251c89be4 --- /dev/null +++ b/apple/TableProTests/Core/Autocomplete/CompletionIdentifierCaseTests.swift @@ -0,0 +1,184 @@ +import Foundation +@testable import TablePro +import TableProAutocomplete +import TableProPluginKit +import Testing + +@MainActor +struct CompletionIdentifierCaseTests { + private func makeEngine(_ type: DatabaseType) async -> CompletionEngine { + let provider = SQLSchemaProvider(metadataSource: SQLSchemaProvider.ColumnMetadataSource( + fetchColumns: { table, _ in + guard table.uppercased() == "MY_TABLE" else { return [] } + return [ + TestFixtures.makeColumnInfo(name: "ORDER_ID"), + TestFixtures.makeColumnInfo(name: "CreatedAt"), + ] + }, + fetchAllColumns: { [:] } + )) + await provider.updateTables([ + TestFixtures.makeTableInfo(name: "MY_TABLE"), + TestFixtures.makeTableInfo(name: "MixedCase"), + ]) + return CompletionEngine(schemaProvider: provider, databaseType: type) + } + + private func inserted( + _ text: String, + cursor: Int? = nil, + type: DatabaseType = .snowflake, + identifierCase: SQLIdentifierCase + ) async -> [String] { + let engine = await makeEngine(type) + let context = await engine.getCompletions( + text: text, + cursorPosition: cursor ?? (text as NSString).length, + identifierCase: identifierCase + ) + return context?.items.map(\.insertText) ?? [] + } + + @Test("On Snowflake, matching the typing completes my_t to my_table and keeps a mixed-case table quoted") + func snowflakeMatchesTheTyping() async { + let lowered = await inserted("select * from my_t", identifierCase: .matchTyped) + #expect(lowered.contains("my_table")) + #expect(!lowered.contains("MY_TABLE")) + + let mixed = await inserted("select * from mix", identifierCase: .matchTyped) + #expect(mixed.contains("\"MixedCase\"")) + } + + @Test("As stored keeps the stored spelling, and quotes only what bare would not reach") + func asStoredKeepsTheSpelling() async { + #expect(await inserted("select * from my_t", identifierCase: .asStored).contains("MY_TABLE")) + #expect(await inserted("select * from mix", identifierCase: .asStored).contains("\"MixedCase\"")) + } + + @Test("Columns follow the setting after a table and a dot") + func columnsAfterADot() async { + let columns = await inserted("select my_table.ord", identifierCase: .lower) + #expect(columns.contains("order_id")) + + let mixed = await inserted("select my_table.cre", identifierCase: .lower) + #expect(mixed.contains("\"CreatedAt\"")) + } + + @Test("Inside a closed quote the stored name goes in raw") + func insideAClosedQuote() async { + let text = "select * from \"my_t\"" + let items = await inserted(text, cursor: (text as NSString).length - 1, identifierCase: .lower) + #expect(items.contains("MY_TABLE")) + #expect(!items.contains("\"MY_TABLE\"")) + } + + @Test("Re-ranking an open session spells the names the same way") + func reRankingSpells() async throws { + let engine = await makeEngine(.snowflake) + let text = "select * from my" + let context = try #require(await engine.getCompletions( + text: text, + cursorPosition: (text as NSString).length, + identifierCase: .lower + )) + let ranked = engine.rank( + context.candidates, + prefix: "my_ta", + context: context.sqlContext, + keywordCase: .default, + identifierCase: .lower + ) + #expect(ranked.map(\.insertText).contains("my_table")) + } + + @Test("An engine without identifier rules is untouched") + func engineWithoutRules() async { + let items = await inserted("select * from mix", type: .mysql, identifierCase: .lower) + #expect(items.contains("MixedCase")) + } + + @Test("PostgreSQL, PGlite, Oracle and Snowflake carry their folding; MySQL has none") + func curatedRules() { + #expect(DatabaseType.postgresql.identifierRules?.folding == .lower) + #expect(DatabaseType.pglite.identifierRules?.folding == .lower) + #expect(DatabaseType.oracle.identifierRules?.folding == .upper) + #expect(DatabaseType.snowflake.identifierRules?.folding == .upper) + #expect(DatabaseType.mysql.identifierRules == nil) + #expect(DatabaseType.postgresql.identifierRules?.reservedWords.contains("USER") == true) + } + + @Test("An Oracle column named after a clock value or pseudocolumn stays quoted") + func oracleBuiltInValuesStayQuoted() throws { + let rules = try #require(DatabaseType.oracle.identifierRules) + let spelling = SQLIdentifierSpelling(rules: rules, policy: .lower) + for name in ["CURRENT_DATE", "SYSTIMESTAMP", "ORA_ROWSCN", "ROWNUM", "USER"] { + #expect(spelling.form(of: name) == .quoted, "\(name)") + } + let quote: (String) -> String = { "\"\($0)\"" } + #expect(spelling.statementName("CURRENT_DATE", quotedBy: quote) == "\"CURRENT_DATE\"") + } + + @Test("A cached table in another schema keeps its own spelling, not a namesake's") + func cachedTableSpellingIsPerSchema() async throws { + let driver = MockDatabaseDriver() + driver.currentSchema = "public" + driver.allColumnsToReturn = ["Orders": [TestFixtures.makeColumnInfo(name: "id")]] + driver.columnsToReturn = ["orders": [TestFixtures.makeColumnInfo(name: "id")]] + let provider = SQLSchemaProvider() + await provider.resetForDatabase( + "db", + tables: [ + TestFixtures.makeTableInfo(name: "Orders", schema: "public"), + TestFixtures.makeTableInfo(name: "orders", schema: "sales"), + ], + driver: driver + ) + await provider.requestColumnPreload() + await provider.waitForEagerColumnLoad() + _ = await provider.getColumns(for: "orders", schema: "sales") + + let rules = try #require(DatabaseType.postgresql.identifierRules) + let items = SQLIdentifierSpelling(rules: rules, policy: .asStored).applied( + to: await provider.allColumnsFromCachedTables(), + typedPrefix: "", + quoting: .none + ) + + #expect(Set(items.map(\.insertText)) == ["\"Orders\".id", "sales.orders.id"]) + } + + @Test("The rules survive a plugin registering over the curated entry") + func rulesSurvivePluginRegistration() { + let built = PluginMetadataRegistry.shared.buildMetadataSnapshot(from: IdentifierRulesSnowflakePlugin.self) + #expect(built.schema.identifierRules?.folding == .upper) + } + + @Test("The setting is stored only when it differs from As stored") + func settingRoundTrips() { + var connection = DatabaseConnection(name: "Snowflake", type: .snowflake) + #expect(connection.completionIdentifierCase == .asStored) + + connection.completionIdentifierCase = .matchTyped + #expect(connection.additionalFields[DatabaseConnection.completionIdentifierCaseKey] == "matchTyped") + #expect(connection.completionIdentifierCase == .matchTyped) + + connection.completionIdentifierCase = .asStored + #expect(connection.additionalFields[DatabaseConnection.completionIdentifierCaseKey] == nil) + } +} + +private final class IdentifierRulesSnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "Mock Snowflake" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Stands in for the registry-distributed Snowflake plugin" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "Snowflake" + static let databaseDisplayName = "Snowflake" + static let iconName = "snowflake-icon" + static let defaultPort = 443 + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + fatalError("Not used in tests") + } +} diff --git a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index 64c3aae58e..4190a15876 100644 --- a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -5,6 +5,7 @@ import Foundation @testable import TablePro +import TableProAutocomplete import TableProPluginKit import Testing @@ -72,6 +73,35 @@ struct SQLRowToStatementConverterTests { rows.map { SQLRowToStatementConverter.SourceRow(values: $0) } } + // MARK: - Identifier case + + private func spelledConverter(_ policy: SQLIdentifierCase) throws -> SQLRowToStatementConverter { + try SQLRowToStatementConverter( + tableName: "MY_TABLE", + columns: ["ID", "MixedCase", "ORDER"], + primaryKeyColumns: ["ID"], + databaseType: .snowflake, + dialect: Self.postgresDialect, + identifierSpelling: SQLIdentifierSpelling( + rules: SQLIdentifierRules(folding: .upper, reservedWords: ["ORDER"]), + policy: policy + ) + ) + } + + @Test("A connection set to lowercase writes folded names bare and quotes the rest as stored") + func lowercaseIdentifierCase() throws { + let result = try spelledConverter(.lower).generateInserts(rows: plain([["1", "a", "b"]])) + #expect(result == "INSERT INTO my_table (id, \"MixedCase\", \"ORDER\") VALUES ('1', 'a', 'b');") + } + + @Test("As stored and match what I type keep every name quoted, as before") + func storedIdentifierCaseQuotesEverything() throws { + let expected = "INSERT INTO \"MY_TABLE\" (\"ID\", \"MixedCase\", \"ORDER\") VALUES ('1', 'a', 'b');" + #expect(try spelledConverter(.asStored).generateInserts(rows: plain([["1", "a", "b"]])) == expected) + #expect(try spelledConverter(.matchTyped).generateInserts(rows: plain([["1", "a", "b"]])) == expected) + } + // MARK: - INSERT Generation @Test("Single row produces one INSERT statement") diff --git a/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift b/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift index deca6905e1..74f9d02bfe 100644 --- a/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift +++ b/apple/TableProTests/ViewModels/ConnectionFormEditsTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProAutocomplete import TableProPluginKit import TableProSSHConfig import Testing @@ -15,6 +16,7 @@ struct ConnectionFormEditsTests { iconName: String? = nil, connectTimeoutSeconds: Int? = nil, queryTimeoutSeconds: Int? = nil, + completionIdentifierCase: SQLIdentifierCase = .default, additionalFields: [String: String] = [:], ownedAdditionalFieldIDs: Set = [] ) -> ConnectionFormEdits { @@ -44,6 +46,7 @@ struct ConnectionFormEditsTests { externalAccess: .readOnly, connectTimeoutSeconds: connectTimeoutSeconds, queryTimeoutSeconds: queryTimeoutSeconds, + completionIdentifierCase: completionIdentifierCase, redisDatabase: nil, startupCommands: nil, localOnly: false, @@ -130,6 +133,15 @@ struct ConnectionFormEditsTests { #expect(result.iconName == "made.up.symbol") } + @Test("Applying edits stores the identifier case, and As stored clears it") + func storesIdentifierCase() { + let picked = edits(completionIdentifierCase: .lower).applied(to: populatedConnection()) + #expect(picked.completionIdentifierCase == .lower) + + let cleared = edits(completionIdentifierCase: .asStored).applied(to: picked) + #expect(cleared.additionalFields[DatabaseConnection.completionIdentifierCaseKey] == nil) + } + @Test("Applying edits stores both connection timeout overrides") func storesTimeoutOverrides() { let result = edits(connectTimeoutSeconds: 45, queryTimeoutSeconds: 0) diff --git a/apple/changelog.d/3731.added.md b/apple/changelog.d/3731.added.md new file mode 100644 index 0000000000..4d321c665e --- /dev/null +++ b/apple/changelog.d/3731.added.md @@ -0,0 +1 @@ +Snowflake, Oracle and PostgreSQL connections can complete table and column names in the case you type or pick. diff --git a/apple/changelog.d/3731.fixed.md b/apple/changelog.d/3731.fixed.md new file mode 100644 index 0000000000..4595791eee --- /dev/null +++ b/apple/changelog.d/3731.fixed.md @@ -0,0 +1 @@ +Autocomplete inserts a mixed-case or reserved table or column name bare on PostgreSQL, Oracle and Snowflake.