Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- An AWS profile backed by IAM Identity Center, or an assume-role chain rooted on one, failing to authenticate.
- AWS SSO sign-in leaving the `aws` CLI unable to refresh its own token.
- AWS SSO, STS and RDS unreachable in the China, GovCloud and secret partitions.
- Auto-uppercase keywords leaving `ADD`, `MERGE`, `CALL`, `COMMENT` and 17 more in the case they were typed.
- Autocomplete committing a different column between launches when two scored the same.
- MongoDB autocomplete inserting `$MATCH` and `DB`, which the server rejects.
- ClickHouse autocomplete offering 18 function names the server rejects, `TOSTRING` and `UNIQ` among them.
Expand Down
10 changes: 9 additions & 1 deletion TablePro/Core/Autocomplete/SQLKeywords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,19 @@ enum SQLKeywords {
"INSERT", "INTO", "VALUES", "DEFAULT",
"UPDATE", "SET",
"DELETE", "TRUNCATE",
"MERGE", "UPSERT", "REPLACE", "CALL",

// DDL
"CREATE", "ALTER", "DROP", "RENAME", "MODIFY",
"TABLE", "VIEW", "INDEX", "DATABASE", "SCHEMA",
"COLUMN", "CONSTRAINT", "PRIMARY", "FOREIGN", "KEY",
"REFERENCES", "UNIQUE", "CHECK",
"AUTO_INCREMENT", "AUTOINCREMENT", "SERIAL",
"ADD", "CHANGE", "AFTER", "COMMENT", "COLLATE", "CHARSET",
"ENGINE", "TABLESPACE",

// Index access methods, offered after USING in CREATE INDEX
"BTREE", "HASH", "GIN", "GIST",

// Data types (common)
"INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT",
Expand All @@ -71,18 +77,20 @@ enum SQLKeywords {

// Window clause
"OVER", "PARTITION", "UNBOUNDED", "PRECEDING", "FOLLOWING", "CURRENT ROW",
"RANGE", "GROUPS",

// PostgreSQL
"RETURNING", "LATERAL", "CONCURRENTLY", "CONFLICT", "EXCLUDED",

// MySQL
"STRAIGHT_JOIN", "FORCE INDEX", "USE INDEX",
"SIGNED", "UNSIGNED",

// DCL
"GRANT", "REVOKE", "PRIVILEGES", "USAGE",

// Utility
"DEALLOCATE", "PREPARE", "EXECUTE",
"DEALLOCATE", "PREPARE", "EXECUTE", "USE",

// Other
"WITH", "RECURSIVE", "TEMPORARY", "TEMP",
Expand Down
118 changes: 118 additions & 0 deletions TableProTests/Core/Autocomplete/KeywordVocabularyParityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// KeywordVocabularyParityTests.swift
// TableProTests
//
// Two lists decide what counts as a SQL keyword, and nothing made them agree. The completion
// provider offers keywords from clause-specific arrays written inline in `getCandidates`, while
// Auto-uppercase consults `SQLKeywords.keywordSet` alone, so a word the popup had offered a
// keystroke earlier was left in lower case: `alter table users add column note text;` came out as
// `ALTER TABLE users add COLUMN note TEXT;`.
//

import Foundation
@testable import TablePro
import Testing

@Suite("Keyword vocabulary parity")
struct KeywordVocabularyParityTests {
/// The uppercaser only ever looks at one word at a time, so a multi-word entry such as
/// `ORDER BY` can never match and is not expected in the set.
@Test("Every single-word keyword the provider offers is one the uppercaser knows")
func offeredKeywordsAreUppercasable() throws {
let offered = try Self.providerOfferedKeywords()
#expect(offered.count > 50, "The provider source parsed to a real vocabulary")

let unknown = offered
.filter { $0.allSatisfy(\.isLetter) || $0.contains("_") }
.filter { !SQLKeywords.keywordSet.contains($0.lowercased()) }
.sorted()

#expect(
unknown.isEmpty,
"""
SQLCompletionProvider offers these as keyword completions, but SQLKeywords.keywordSet \
does not contain them, so Auto-uppercase leaves them in whatever case the user typed \
while uppercasing the words around them: \(unknown)
"""
)
}

@Test("The uppercaser's set holds only single words, in lower case")
func keywordSetShape() {
#expect(!SQLKeywords.keywordSet.isEmpty)
#expect(SQLKeywords.keywordSet.allSatisfy { !$0.contains(" ") })
#expect(SQLKeywords.keywordSet.allSatisfy { $0 == $0.lowercased() })
}

/// The words the reported defect named, kept as an explicit list so a future edit that drops
/// one fails here rather than quietly reopening the gap.
@Test(
"The keywords the gap was reported against are uppercasable",
arguments: [
"add", "change", "after", "comment", "collate", "charset", "engine",
"tablespace", "merge", "upsert", "call", "use", "replace",
"signed", "unsigned", "range", "groups", "btree", "hash", "gin", "gist"
]
)
func reportedKeywordsAreKnown(keyword: String) {
#expect(SQLKeywords.keywordSet.contains(keyword))
}

/// The reported statement, walked word by word the way the uppercaser sees it. Every keyword in
/// it is now recognised; `users` and `note` are identifiers and stay untouched.
@Test("The reported statement uppercases every keyword and nothing else")
func reportedStatementUppercasesConsistently() {
let statement = "alter table users add column note text"
let words = statement.split(separator: " ").map(String.init)
let recognised = words.filter { SQLKeywords.keywordSet.contains($0) }
#expect(recognised == ["alter", "table", "add", "column", "text"])

let text = statement as NSString
let endOfAdd = text.range(of: "add").location + 3
#expect(KeywordUppercaseHelper.keywordBeforePosition(text, at: endOfAdd)?.word == "add")

let endOfUsers = text.range(of: "users").location + 5
#expect(KeywordUppercaseHelper.keywordBeforePosition(text, at: endOfUsers) == nil)
}

private static func providerOfferedKeywords(file: StaticString = #filePath) throws -> Set<String> {
let source = try repositoryRoot(file: file)
.appendingPathComponent("TablePro/Core/Autocomplete/SQLCompletionProvider.swift")
let text = try String(contentsOf: source, encoding: .utf8)

var found: Set<String> = []
for marker in ["filterKeywords([", "boostedKeywords([", "statementStartKeywords = ["] {
var searchStart = text.startIndex
while let open = text.range(of: marker, range: searchStart..<text.endIndex) {
guard let close = text.range(of: "]", range: open.upperBound..<text.endIndex) else { break }
found.formUnion(Self.quotedStrings(in: text[open.upperBound..<close.lowerBound]))
searchStart = close.upperBound
}
}
guard !found.isEmpty else { throw ParityError.sourceNotFound }
return found
}

private static func quotedStrings(in fragment: Substring) -> [String] {
fragment
.split(separator: "\n")
.map { $0.components(separatedBy: "//").first ?? "" }
.flatMap { $0.components(separatedBy: ",") }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: ["\""]) }
.filter { !$0.isEmpty }
}

private static func repositoryRoot(file: StaticString) throws -> URL {
var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent()
while directory.path != "/" {
let candidate = directory.appendingPathComponent("TablePro/Core/Autocomplete/SQLKeywords.swift")
if FileManager.default.fileExists(atPath: candidate.path) { return directory }
directory = directory.deletingLastPathComponent()
}
throw ParityError.sourceNotFound
}

private enum ParityError: Error {
case sourceNotFound
}
}
Loading