git.iko.soy/parseable-macos
on master help sign in
Fix query editor and column filter bugs, including a crash

- addColumnFilter crashed (Range lowerBound > upperBound) when an ORDER/GROUP/LIMIT/OFFSET keyword appeared before WHERE, e.g. a field named `offset` or the first SELECT of a UNION. Clause detection now starts after the top-level FROM and requires ORDER/GROUP BY or a row count after LIMIT/OFFSET. - Filter by value produced broken SQL with a trailing `;`, a trailing or in-body `--` comment (the condition and ORDER BY were commented out), or a clause glued to the body. The condition is now spliced in after the last real token, keeping the user's line breaks. - Exclude now uses IS DISTINCT FROM, since `<>` also dropped every row where the column is NULL. Filtering on an array/object value shows an error instead of running a query that fails and clears the results. Filtering results of a blank editor seeds the default query first. - SQLTokenizer: CRLF is a single Swift Character, so it was neither whitespace nor a line-comment terminator. - Column manager: no longer rewrites aggregate/alias SELECT lists into `*` or bare result keys, no longer bakes auto-hidden (empty) columns into the SQL, keeps listed columns missing from the JSON, and keeps hidden-column and column-order preferences for absent columns. - Autocomplete quotes column names DataFusion would lowercase or misparse (mixed case, dots, reserved words), absorbs typed quotes, offers nothing inside comments/string literals, and syncs the popup's clicked row with Return/Tab. The popup hides when the editor loses focus. - SQL editor: model-driven text changes are registered with the undo manager, so Cmd+Z no longer replays stale ranges on the new text. - Syntax highlighting scans strings, identifiers and comments in one pass, so an apostrophe in a comment no longer paints later lines as a string; block comments are highlighted. - The Query tab's view model now outlives tab switches (it was rebuilt on every switch, discarding the query, results and time range), and resets when the stream or server changed in the meantime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015p9SN94YqDtQkEisiPQ3xJ

commitc040e748507e96ae2bb11797f48de885b0721d7f authorClaude <noreply@anthropic.com> dateWed 30 Sep 2026, 10:49 (2 weeks ago) parentc043310
11 files changed+704−139
ParseableViewer/Models/SQLTokenizer.swift+18−4 ParseableViewer/ViewModels/QueryViewModel.swift+248−49 ParseableViewer/Views/ContentView.swift+26−1 ParseableViewer/Views/QueryView.swift+43−9 ParseableViewer/Views/SQLAutoComplete.swift+98−11 ParseableViewer/Views/SQLEditorView.swift+41−6 ParseableViewer/Views/SyntaxHighlighting.swift+27−57 ParseableViewerTests/QueryViewModelTests.swift+122−2 ParseableViewerTests/SQLAutoCompleteTests.swift+56 ParseableViewerTests/SQLTokenizerTests.swift+15 ParseableViewerTests/SyntaxHighlightingTests.swift+10
ParseableViewer/Models/SQLTokenizer.swift+18−4
@@ -80,8 +80,8 @@ struct SQLTokenizer: Sendable {
8080
8181 switch c {
8282 // Whitespace
83− case " ", "\t", "\n", "\r":
84− advance(&i, in: sql, while: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
83+ case _ where isWhitespace(c):
84+ advance(&i, in: sql, while: isWhitespace)
8585 tokens.append(Token(kind: .whitespace, range: start..<i))
8686
8787 // Line comment
@@ -89,8 +89,11 @@ struct SQLTokenizer: Sendable {
8989 // Stop at CR as well as LF: with CRLF (`\r\n`) line endings, consuming
9090 // the trailing `\r` into the comment token would push its boundary past
9191 // where `characterOffset`/DataFusion treat the line as ending, shifting
92− // the server-error highlight range. `\r` is then tokenized as whitespace.
93− advance(&i, in: sql, while: { $0 != "\n" && $0 != "\r" })
92+ // the server-error highlight range. The line break is then tokenized as
93+ // whitespace. `isLineBreak` also matches the single `"\r\n"` Character
94+ // Swift forms from a CRLF pair — comparing against "\n"/"\r" alone never
95+ // matches it, so the comment would swallow every following line.
96+ advance(&i, in: sql, while: { !isLineBreak($0) })
9497 tokens.append(Token(kind: .lineComment, range: start..<i))
9598
9699 // Block comment
@@ -259,6 +262,17 @@ struct SQLTokenizer: Sendable {
259262 return value // unterminated
260263 }
261264
265+ /// Swift iterates grapheme clusters, and a CRLF pair is the single Character
266+ /// `"\r\n"`, which is equal to neither `"\r"` nor `"\n"`. Match it explicitly
267+ /// so Windows line endings (e.g. pasted text) tokenize like LF/CR ones.
268+ private static func isLineBreak(_ c: Character) -> Bool {
269+ c == "\n" || c == "\r" || c == "\r\n"
270+ }
271+
272+ private static func isWhitespace(_ c: Character) -> Bool {
273+ c == " " || c == "\t" || isLineBreak(c)
274+ }
275+
262276 /// SQL number literals use only ASCII digits. `Character.isNumber` also
263277 /// matches Unicode numerics (`²`, `½`, Arabic-Indic digits, etc.), which
264278 /// DataFusion's lexer rejects — accepting them here would tokenize those
ParseableViewer/ViewModels/QueryViewModel.swift+248−49
@@ -59,9 +59,29 @@ final class QueryViewModel {
5959 var columns: [String] = []
6060 var columnOrder: [String] = []
6161 var hiddenColumns: Set<String> = []
62+ /// The full persisted set of hidden columns for the current stream, including
63+ /// ones absent from the current result. Hiding a column drops it from the
64+ /// SELECT list, so it's missing from the very next result and `hiddenColumns`
65+ /// (only the columns present) forgets it — the next save would then erase the
66+ /// preference. This keeps the intent, mirroring LiveTailViewModel.
67+ private(set) var savedHiddenColumns: Set<String> = []
68+ /// The persisted column order including columns absent from the current
69+ /// result (the order counterpart of `savedHiddenColumns`), so saving doesn't
70+ /// drop a hidden column's position and it returns to its place when shown.
71+ private var savedColumnOrder: [String] = []
6272 var autoHiddenColumns: Set<String> = []
6373 /// Column config from a saved query, applied on next query execution.
6474 var pendingColumnConfig: ColumnConfiguration?
75+
76+ /// The connection and stream the editor text and results belong to. The view
77+ /// model outlives QueryView (switching tabs keeps the query), so the view
78+ /// compares this on appear to catch a stream/server change made while another
79+ /// tab was showing.
80+ struct BoundContext: Equatable {
81+ let connectionID: UUID?
82+ let stream: String
83+ }
84+ var boundContext: BoundContext?
6585 private var currentStream: String?
6686 var isLoading = false
6787 var errorMessage: String?
@@ -192,9 +212,16 @@ final class QueryViewModel {
192212 return columnOrder.filter { !allHidden.contains($0) }
193213 }
194214
215+ /// Whether any column is hidden — including saved-hidden columns that aren't
216+ /// in the current result, which "Show All" can bring back.
217+ var hasHiddenColumns: Bool {
218+ !hiddenColumns.isEmpty || !autoHiddenColumns.isEmpty || !savedHiddenColumns.isEmpty
219+ }
220+
195221 func toggleColumnVisibility(_ column: String) {
196222 if hiddenColumns.contains(column) {
197223 hiddenColumns.remove(column)
224+ savedHiddenColumns.remove(column)
198225 } else if autoHiddenColumns.contains(column) {
199226 autoHiddenColumns.remove(column)
200227 } else {
@@ -203,6 +230,7 @@ final class QueryViewModel {
203230 let visibleCount = columnOrder.count - allHidden.count
204231 if visibleCount > 1 {
205232 hiddenColumns.insert(column)
233+ savedHiddenColumns.insert(column)
206234 }
207235 }
208236 saveColumnConfig()
@@ -211,6 +239,7 @@ final class QueryViewModel {
211239
212240 func showAllColumns() {
213241 hiddenColumns.removeAll()
242+ savedHiddenColumns.removeAll()
214243 autoHiddenColumns.removeAll()
215244 saveColumnConfig()
216245 updateSQLColumns()
@@ -240,32 +269,116 @@ final class QueryViewModel {
240269
241270 func resetColumnConfig() {
242271 columnOrder = columns
272+ savedColumnOrder = []
243273 hiddenColumns.removeAll()
274+ savedHiddenColumns.removeAll()
244275 autoHiddenColumns.removeAll()
245276 saveColumnConfig()
246277 updateSQLColumns()
247278 }
248279
249− /// Rewrites the SELECT clause of `sqlQuery` to reflect the current `visibleColumns`.
280+ /// Rewrites the SELECT clause of `sqlQuery` to reflect the user's column
281+ /// choices (order and hidden columns; auto-hidden columns stay display-only).
250282 func updateSQLColumns() {
251− guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery) else { return }
252−
253− let visible = visibleColumns
283+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
284+ let currentList = Self.parseColumnList(sqlQuery[range]) else { return }
285+
286+ // Project the user's choices only. `autoHiddenColumns` is a display
287+ // heuristic for the current rows (the field is empty in all of them);
288+ // baking it into the SQL would stop the field from ever being fetched
289+ // again, even for a time range where it has values.
290+ let visible = columnOrder.filter { !hiddenColumns.contains($0) }
254291 guard !visible.isEmpty else { return }
255292
293+ // Columns an explicit list names that aren't in the results: Parseable
294+ // omits all-null fields from query JSON, so a sparse listed field can be
295+ // missing from `columns`. Keep it (unless the user hid it) rather than
296+ // silently dropping it from the SELECT on the next rewrite.
297+ var listedButAbsent: [String] = []
298+ var excludesHiddenColumn = false
299+ if case .identifiers(let names) = currentList {
300+ let present = Set(columns)
301+ listedButAbsent = names.filter { !present.contains($0) && !savedHiddenColumns.contains($0) }
302+ // A column the user hid is absent from these results precisely
303+ // because this list was narrowed to exclude it.
304+ excludesHiddenColumn = !savedHiddenColumns.isSubset(of: present)
305+ }
306+
256307 let newColumnList: String
257308 // Use `*` whenever every column is visible, regardless of display order —
258309 // reordering columns (a set-preserving permutation) must not pin the query
259− // to an explicit snapshot that hides newly-appearing fields on re-query.
260− if visible.count == columns.count && Set(visible) == Set(columns) {
310+ // to an explicit snapshot that hides newly-appearing fields on re-query —
311+ // unless the list is explicit because it leaves out a hidden column, which
312+ // `*` would fetch again.
313+ if !excludesHiddenColumn && visible.count == columns.count && Set(visible) == Set(columns) {
261314 newColumnList = "*"
262315 } else {
263− newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
316+ newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
264317 }
265318
266319 sqlQuery.replaceSubrange(range, with: newColumnList)
267320 }
268321
322+ enum ColumnListShape: Equatable {
323+ case star
324+ /// Plain column references, as the column names they resolve to.
325+ case identifiers([String])
326+ }
327+
328+ /// Parses a SELECT list the column manager can regenerate without changing
329+ /// what the query computes: `*`, or a comma-separated list of plain column
330+ /// references. Anything else — aggregates, expressions, aliases
331+ /// (`count(*) AS n`), qualified names — returns nil: rewriting it with `*` or
332+ /// bare result keys would break the query, so the column manager then only
333+ /// hides/reorders client-side.
334+ nonisolated static func parseColumnList(_ list: Substring) -> ColumnListShape? {
335+ let tokens = SQLTokenizer.tokenize(String(list)).filter { !$0.kind.isTrivia }
336+ if tokens.count == 1, tokens[0].kind == .star { return .star }
337+ guard tokens.count % 2 == 1 else { return nil }
338+ var names: [String] = []
339+ for (index, token) in tokens.enumerated() {
340+ if index % 2 == 1 {
341+ guard token.kind == .comma else { return nil }
342+ continue
343+ }
344+ switch token.kind {
345+ // DataFusion folds unquoted identifiers to lowercase.
346+ case .identifier(let name): names.append(name.lowercased())
347+ case .quotedIdentifier(let name): names.append(name)
348+ default: return nil
349+ }
350+ }
351+ return .identifiers(names)
352+ }
353+
354+ /// Inserts `newColumns` into `order`, placing each one that appears in
355+ /// `reference` (a saved order) right after its nearest saved predecessor
356+ /// that's already placed — or first if none is — and appending the rest.
357+ /// Used to keep the positions of columns that are absent for a while.
358+ nonisolated static func mergedColumnOrder(
359+ _ order: [String], inserting newColumns: [String], reference: [String]
360+ ) -> [String] {
361+ var result = order
362+ let referenceIndex = Dictionary(reference.enumerated().map { ($1, $0) }, uniquingKeysWith: { first, _ in first })
363+ let pending = newColumns.filter { referenceIndex[$0] == nil }
364+ let positioned = newColumns
365+ .filter { referenceIndex[$0] != nil }
366+ .sorted { referenceIndex[$0]! < referenceIndex[$1]! }
367+ for column in positioned where !result.contains(column) {
368+ let savedIndex = referenceIndex[column]!
369+ let predecessor = reference[..<savedIndex].reversed().first { result.contains($0) }
370+ if let predecessor, let position = result.firstIndex(of: predecessor) {
371+ result.insert(column, at: position + 1)
372+ } else {
373+ result.insert(column, at: 0)
374+ }
375+ }
376+ for column in pending where !result.contains(column) {
377+ result.append(column)
378+ }
379+ return result
380+ }
381+
269382 // MARK: - Column Configuration Persistence
270383
271384 struct ColumnConfiguration: Codable {
@@ -279,7 +392,16 @@ final class QueryViewModel {
279392
280393 private func saveColumnConfig() {
281394 guard let stream = currentStream else { return }
282− let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
395+ // Persist the full intended hidden set and order, not just the currently
396+ // present columns (see `savedHiddenColumns` / `savedColumnOrder`).
397+ let present = Set(columnOrder)
398+ let order = Self.mergedColumnOrder(
399+ columnOrder,
400+ inserting: savedColumnOrder.filter { !present.contains($0) },
401+ reference: savedColumnOrder
402+ )
403+ savedColumnOrder = order
404+ let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
283405 if let data = try? JSONEncoder().encode(config) {
284406 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
285407 }
@@ -308,7 +430,9 @@ final class QueryViewModel {
308430
309431 guard let config else {
310432 columnOrder = extractedColumns
433+ savedColumnOrder = []
311434 hiddenColumns = []
435+ savedHiddenColumns = []
312436 return
313437 }
314438
@@ -321,6 +445,8 @@ final class QueryViewModel {
321445 merged.append(col)
322446 }
323447 columnOrder = merged
448+ savedColumnOrder = config.order
449+ savedHiddenColumns = config.hidden
324450 hiddenColumns = config.hidden.intersection(extractedSet)
325451
326452 // The `visibleCount > 1` guard in toggleColumnVisibility only covers
@@ -519,6 +645,8 @@ final class QueryViewModel {
519645 // report stale column names after the failure.
520646 columnOrder = []
521647 hiddenColumns = []
648+ savedHiddenColumns = []
649+ savedColumnOrder = []
522650 autoHiddenColumns = []
523651 }
524652 }
@@ -685,6 +813,8 @@ final class QueryViewModel {
685813 columns = []
686814 columnOrder = []
687815 hiddenColumns = []
816+ savedHiddenColumns = []
817+ savedColumnOrder = []
688818 autoHiddenColumns = []
689819 currentStream = nil
690820 resultCount = 0
@@ -698,6 +828,7 @@ final class QueryViewModel {
698828 // a later reload guard never sees a non-nil stream paired with empty data.
699829 latestSchemaStream = nil
700830 latestQueryStream = nil
831+ boundContext = nil
701832 }
702833
703834 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -707,75 +838,139 @@ final class QueryViewModel {
707838 let condition: String
708839 if let value, value != .null {
709840 let literal = value.sqlLiteral
710− condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
841+ // Exclude with `IS DISTINCT FROM`, not `<>`: under SQL's three-valued
842+ // logic `NULL <> 'x'` is NULL, so `<>` also dropped every row where the
843+ // column is missing/NULL — common for sparse fields — which "exclude
844+ // this value" doesn't ask for (the live-tail exclude keeps them too).
845+ condition = exclude
846+ ? "\(escapedCol) IS DISTINCT FROM \(literal)"
847+ : "\(escapedCol) = \(literal)"
711848 } else {
712849 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
713850 }
714851
715− var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
852+ let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
716853 if sql.isEmpty { return }
717854
718855 // Locate the top-level WHERE and the first trailing clause using the
719856 // tokenizer rather than a raw regex, so keywords inside quoted
720857 // identifiers, string literals, comments, or subqueries are ignored.
721− let tailKeywords: Set<String> = ["ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET"]
858+ let tokens = SQLTokenizer.tokenize(sql)
722859 var depth = 0
723− var whereEnd: String.Index?
724− var tailStart: String.Index?
860+ // WHERE and the trailing clauses only follow the top-level FROM. Keywords
861+ // before it belong to the SELECT list — e.g. a field named `offset` or
862+ // `limit` (which the tokenizer reports as keywords) — and must not be
863+ // mistaken for clause starts.
864+ var fromSeen = false
865+ var whereIndex: Int?
866+ var tailIndex: Int?
725867 // Track whether the existing WHERE body has a top-level OR — only then does
726868 // AND-ing a new condition need parentheses to preserve precedence.
727869 var bodyHasTopLevelOr = false
728− for token in SQLTokenizer.tokenize(sql) {
870+ // The last token that is actual SQL — not whitespace, a comment, or a
871+ // statement-terminating `;`. New text is spliced in right after such
872+ // tokens (never at the raw end of the string), so a trailing `;` or a
873+ // trailing `-- comment` stays at the end instead of swallowing the
874+ // appended condition: `... LIMIT 10;` must not become `... LIMIT 10; WHERE`,
875+ // and `... -- note` must not become `... -- note WHERE <commented out>`.
876+ func isSignificant(_ token: SQLTokenizer.Token) -> Bool {
877+ !token.kind.isTrivia && token.kind != .other(";")
878+ }
879+ func nextSignificantKind(after index: Int) -> SQLTokenizer.TokenKind? {
880+ tokens[(index + 1)...].first(where: { isSignificant($0) })?.kind
881+ }
882+ // Whether the keyword at `index` actually opens a trailing clause. ORDER and
883+ // GROUP must be followed by BY (so `WITHIN GROUP (…)` or a column named
884+ // `order` doesn't count), and LIMIT/OFFSET by their row count (so a column
885+ // named `offset` in `WHERE offset > 5` doesn't cut the WHERE body short).
886+ func startsTrailingClause(_ keyword: String, at index: Int) -> Bool {
887+ switch keyword {
888+ case "ORDER", "GROUP":
889+ return nextSignificantKind(after: index) == .keyword("BY")
890+ case "LIMIT", "OFFSET":
891+ if case .number = nextSignificantKind(after: index) { return true }
892+ return false
893+ case "HAVING":
894+ return true
895+ default:
896+ return false
897+ }
898+ }
899+ var lastSignificant: Int?
900+ for (idx, token) in tokens.enumerated() {
901+ if isSignificant(token) { lastSignificant = idx }
729902 switch token.kind {
730903 case .leftParen:
731904 depth += 1
732905 case .rightParen:
733906 depth = max(0, depth - 1)
734− case .keyword(let kw) where depth == 0:
735− if kw == "WHERE", whereEnd == nil {
736− whereEnd = token.range.upperBound
737− } else if tailKeywords.contains(kw), tailStart == nil {
738− tailStart = token.range.lowerBound
739− } else if kw == "OR", whereEnd != nil, tailStart == nil {
907+ case .keyword("FROM") where depth == 0:
908+ fromSeen = true
909+ case .keyword(let kw) where depth == 0 && fromSeen:
910+ if kw == "WHERE", whereIndex == nil {
911+ whereIndex = idx
912+ // A trailing clause seen *before* the WHERE (e.g. the GROUP BY of
913+ // the first SELECT in `... GROUP BY a UNION ALL SELECT ... WHERE`)
914+ // doesn't end this WHERE's body. Keeping it would make the body
915+ // range run backwards (a trapping `Range`), so look for the
916+ // body's end afresh after the WHERE.
917+ tailIndex = nil
918+ } else if tailIndex == nil, startsTrailingClause(kw, at: idx) {
919+ tailIndex = idx
920+ } else if kw == "OR", whereIndex != nil, tailIndex == nil {
740921 bodyHasTopLevelOr = true
741922 }
742923 default:
743924 break
744925 }
745926 }
746−
747− if let whereEnd {
748− // Parenthesize the existing WHERE body before AND-ing the new
749− // condition only when it has a top-level OR, otherwise the OR
750− // silently changes meaning: `WHERE a = 1 OR b = 2` would become
751− // `WHERE a = 1 OR b = 2 AND c = 3`, which SQL binds as
752− // `a = 1 OR (b = 2 AND c = 3)` rather than the intended
753− // `(a = 1 OR b = 2) AND c = 3`. A body without a top-level OR is
754− // left un-parenthesized so simple filters stay readable.
755− let bodyEnd = tailStart ?? sql.endIndex
756− let head = sql[..<whereEnd]
757− let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
758− let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
759− // An empty WHERE body (e.g. a half-written `... WHERE` with no
760− // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
761− // LIMIT) would otherwise produce `WHERE AND <condition>` — a
762− // dangling AND with no left operand. Drop the AND in that case.
763− if body.isEmpty {
764− sql = "\(head) \(condition)"
927+ // Nothing but comments/semicolons — there's no statement to filter.
928+ guard let lastSignificant else { return }
929+
930+ // Each branch rebuilds the query from slices of the original text, keeping
931+ // the user's line breaks: joining pieces with single spaces would pull the
932+ // text after a `-- comment` onto the comment's line and comment it out.
933+ // Text spliced directly in front of another token (`(a = 1)ORDER BY`) needs
934+ // a space so the two don't fuse (`... IS NULLORDER BY`).
935+ func separated(_ rest: Substring) -> String {
936+ guard let next = rest.first, !next.isWhitespace, next != ";" else { return String(rest) }
937+ return " " + rest
938+ }
939+ if let whereIndex {
940+ // The WHERE body runs up to the first trailing clause, or to the end
941+ // of the statement. Only its significant tokens bound the splice, so a
942+ // comment between the body and ORDER BY stays after the new condition.
943+ let bodyEndIndex = tailIndex ?? (lastSignificant + 1)
944+ let bodyIndices = (whereIndex + 1)..<bodyEndIndex
945+ if let first = bodyIndices.first(where: { isSignificant(tokens[$0]) }),
946+ let last = bodyIndices.last(where: { isSignificant(tokens[$0]) }) {
947+ // Parenthesize the existing WHERE body before AND-ing the new
948+ // condition only when it has a top-level OR, otherwise the OR
949+ // silently changes meaning: `WHERE a = 1 OR b = 2` would become
950+ // `WHERE a = 1 OR b = 2 AND c = 3`, which SQL binds as
951+ // `a = 1 OR (b = 2 AND c = 3)` rather than the intended
952+ // `(a = 1 OR b = 2) AND c = 3`. A body without a top-level OR is
953+ // left un-parenthesized so simple filters stay readable.
954+ let bodyStart = tokens[first].range.lowerBound
955+ let bodyEnd = tokens[last].range.upperBound
956+ let body = sql[bodyStart..<bodyEnd]
957+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : String(body)
958+ sqlQuery = "\(sql[..<bodyStart])\(wrappedBody) AND \(condition)\(separated(sql[bodyEnd...]))"
765959 } else {
766− let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
767− sql = "\(head) \(wrappedBody) AND \(condition)"
960+ // An empty WHERE body (e.g. a half-written `... WHERE` with no
961+ // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
962+ // LIMIT) would otherwise produce `WHERE AND <condition>` — a
963+ // dangling AND with no left operand. Drop the AND in that case.
964+ let whereEnd = tokens[whereIndex].range.upperBound
965+ sqlQuery = "\(sql[..<whereEnd]) \(condition)\(separated(sql[whereEnd...]))"
768966 }
769− if !tail.isEmpty {
770− sql += " \(tail)"
771− }
772− } else if let tailStart {
773− sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
967+ } else if let tailIndex {
968+ let tailStart = tokens[tailIndex].range.lowerBound
969+ sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
774970 } else {
775− sql += " WHERE \(condition)"
971+ let statementEnd = tokens[lastSignificant].range.upperBound
972+ sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
776973 }
777−
778− sqlQuery = sql
779974 }
780975
781976 /// Sets the default query for the given stream, returning `true` if the
@@ -819,6 +1014,10 @@ final class QueryViewModel {
8191014 // MARK: - Query History
8201015
8211016 private func addToHistory(_ entry: QueryHistoryEntry) {
1017+ // Start from what's persisted, not this instance's copy: a model created
1018+ // earlier (e.g. one whose query finished after a reconnect replaced it)
1019+ // would otherwise overwrite newer entries saved by the current one.
1020+ queryHistory = Self.loadHistory()
8221021 // Deduplicate consecutive identical queries
8231022 if queryHistory.first?.sql == entry.sql { return }
8241023 queryHistory.insert(entry, at: 0)
ParseableViewer/Views/ContentView.swift+26−1
@@ -31,6 +31,18 @@ struct ContentView: View {
3131
3232struct MainContentView: View {
3333 @Environment(AppState.self) private var appState
34+ /// Kept here rather than inside QueryView so the Query tab's editor text,
35+ /// results and time range survive switching to another tab and back (only
36+ /// the active tab's view is instantiated). Dropped on disconnect, when this
37+ /// view goes away.
38+ @State private var queryViewModel = QueryViewModel()
39+
40+ /// Which server the Query tab's state belongs to. Editing the active
41+ /// connection's URL or user keeps its id (so QueryView's own stream/connection
42+ /// check can't see it), and a rename shouldn't wipe the user's query.
43+ private var serverIdentity: String? {
44+ appState.activeConnection.map { "\($0.id.uuidString)|\($0.url)|\($0.username)" }
45+ }
3446
3547 var body: some View {
3648 @Bindable var appState = appState
@@ -63,7 +75,7 @@ struct MainContentView: View {
6375 } else {
6476 switch appState.currentTab {
6577 case .query:
66− QueryView()
78+ QueryView(viewModel: queryViewModel)
6779 .id(AppState.AppTab.query)
6880 case .liveTail:
6981 LiveTailView()
@@ -83,6 +95,19 @@ struct MainContentView: View {
8395 }
8496 }
8597 }
98+ .onChange(of: serverIdentity) { _, _ in
99+ // The Query tab's model outlives stream and server changes made while
100+ // it isn't on screen. On a new server, drop the old one's results and
101+ // query (cancelling one still in flight) so the tab starts fresh.
102+ queryViewModel.clearResults()
103+ queryViewModel.sqlQuery = ""
104+ queryViewModel.filterText = ""
105+ }
106+ .onDisappear {
107+ // Disconnecting tears this view (and the model) down; don't leave an
108+ // in-flight query running against the server the user just left.
109+ queryViewModel.clearResults()
110+ }
86111 }
87112}
88113
ParseableViewer/Views/QueryView.swift+43−9
@@ -12,7 +12,11 @@ private struct QueryViewContext: Equatable {
1212
1313struct QueryView: View {
1414 @Environment(AppState.self) private var appState
15− @State private var viewModel = QueryViewModel()
15+ /// Owned by MainContentView, not by this view: only the active tab is
16+ /// mounted, so a view-local @State model was rebuilt on every tab switch —
17+ /// discarding the user's query, results, time range and filter and re-running
18+ /// the default query when they came back.
19+ let viewModel: QueryViewModel
1620 @State private var showSaveFilterSheet = false
1721 @State private var saveFilterName = ""
1822 @State private var isSavingFilter = false
@@ -247,6 +251,23 @@ struct QueryView: View {
247251 isLoading: viewModel.isLoading,
248252 wrapText: wrapText,
249253 onCellFilter: { column, value, exclude in
254+ // Arrays/objects are List/Struct columns on the server; a
255+ // `col = '<json>'` comparison fails to plan, and the failed
256+ // run would clear the results the user is looking at.
257+ if let value, !value.isScalar {
258+ viewModel.errorMessage = "Filtering by an array or object value isn't supported in SQL queries."
259+ return
260+ }
261+ // Results from a blank editor come from the auto-generated
262+ // default query, which `addColumnFilter` can't see (it edits
263+ // `sqlQuery`); put that query in the editor so the filter
264+ // actually applies instead of silently re-running unfiltered.
265+ if viewModel.sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
266+ let stream = appState.selectedStream {
267+ // (Passing a previous stream forces the replacement even
268+ // when the blank editor holds only whitespace.)
269+ viewModel.setDefaultQuery(stream: stream, previousStream: stream)
270+ }
250271 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
251272 Task {
252273 await viewModel.executeQuery(
@@ -269,6 +290,9 @@ struct QueryView: View {
269290 let streamChanged = old.stream != new.stream
270291 if streamChanged {
271292 viewModel.clearResults()
293+ viewModel.boundContext = QueryViewModel.BoundContext(
294+ connectionID: appState.activeConnection?.id, stream: stream
295+ )
272296 Task {
273297 await viewModel.loadSchema(client: appState.client, stream: stream)
274298 }
@@ -303,6 +327,21 @@ struct QueryView: View {
303327 }
304328 .onAppear {
305329 if let stream = appState.selectedStream {
330+ // The view model outlives this view. If the stream (or server)
331+ // changed while another tab was showing, the state it kept belongs
332+ // to the old one — reset it exactly like a stream switch would.
333+ let context = QueryViewModel.BoundContext(
334+ connectionID: appState.activeConnection?.id, stream: stream
335+ )
336+ var previousStream: String?
337+ if let bound = viewModel.boundContext, bound != context {
338+ previousStream = bound.stream
339+ viewModel.clearResults()
340+ // A freshly built model (what this view used to get on every
341+ // mount) had no results filter; don't let an old one hide rows.
342+ viewModel.filterText = ""
343+ }
344+ viewModel.boundContext = context
306345 // Reload the schema when it's missing OR when the loaded schema
307346 // belongs to a different stream than the current one — otherwise a
308347 // viewModel that survives with another stream's schema would keep
@@ -328,7 +367,7 @@ struct QueryView: View {
328367 )
329368 }
330369 } else {
331− let didSetDefault = viewModel.setDefaultQuery(stream: stream)
370+ let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: previousStream)
332371 if didSetDefault && viewModel.results.isEmpty {
333372 Task {
334373 await viewModel.executeQuery(
@@ -340,11 +379,6 @@ struct QueryView: View {
340379 }
341380 }
342381 }
343− .onDisappear {
344− if viewModel.isLoading {
345− viewModel.cancelQuery()
346− }
347− }
348382 .onChange(of: appState.queryRefreshToken) { _, _ in
349383 // Guard on a selected stream like every sibling query trigger in this
350384 // view. Without it, a refresh (Cmd+R) with no stream selected and an
@@ -462,7 +496,7 @@ struct ColumnManagerView: View {
462496 Text("Columns")
463497 .font(.headline)
464498 Spacer()
465− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
499+ if viewModel.hasHiddenColumns || viewModel.columnOrder != viewModel.columns {
466500 Button("Reset") {
467501 viewModel.resetColumnConfig()
468502 }
@@ -516,7 +550,7 @@ struct ColumnManagerView: View {
516550 }
517551 .listStyle(.plain)
518552
519− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
553+ if viewModel.hasHiddenColumns {
520554 Divider()
521555 Button("Show All Columns") {
522556 viewModel.showAllColumns()
ParseableViewer/Views/SQLAutoComplete.swift+98−11
@@ -105,23 +105,32 @@ enum SQLCompletionProvider {
105105 return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
106106 }
107107
108+ // Words typed inside a comment or a string literal aren't SQL, so offer
109+ // nothing there. Otherwise the popup opens (e.g. `-- grouped by date`
110+ // matches DATE) and the Return/Tab meant to end the line or keep typing is
111+ // captured to accept a keyword, rewriting the comment or the literal value.
112+ if isInsideCommentOrStringLiteral(text, offset: cursorPosition) {
113+ return ([], prefix, NSRange(location: wordStart, length: wordEnd - wordStart))
114+ }
115+
108116 let context = determineContext(text: text, position: wordStart)
109117
110− // A table completion supplies its own surrounding quotes. If the user has
118+ // Table (and column) completions supply their own quoting. If the user has
111119 // already typed quotes directly around the word (common for stream names
112120 // with spaces, e.g. `FROM "my"`), absorb them into the replacement range so
113121 // the inserted `"name"` replaces the stray quotes instead of producing
114122 // `""name""`. Mirror `insertCompletion`, which absorbs leading and trailing
115− // quotes independently for any `.table` item — so apply this wherever table
116− // completions are offered (`.tableRef` and `.general`), not just `.tableRef`.
123+ // quotes independently for any `.table`/`.column` item — so apply this
124+ // wherever those completions are offered (`.tableRef`, `.columnRef` and
125+ // `.general`).
117126 var replaceStart = wordStart
118127 var replaceEnd = wordEnd
119− let offersTable: Bool
128+ let offersIdentifiers: Bool
120129 switch context {
121− case .tableRef, .general: offersTable = true
122− default: offersTable = false
130+ case .tableRef, .columnRef, .general: offersIdentifiers = true
131+ case .afterOrder, .afterGroup: offersIdentifiers = false
123132 }
124− if offersTable {
133+ if offersIdentifiers {
125134 if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
126135 replaceStart -= 1
127136 }
@@ -147,7 +156,7 @@ enum SQLCompletionProvider {
147156 case .columnRef:
148157 for field in schemaFields.sorted(by: { $0.name < $1.name })
149158 where field.name.uppercased().hasPrefix(uppercasePrefix) {
150− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
159+ items.append(columnItem(for: field))
151160 }
152161 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
153162 items.append(SQLCompletionItem(text: fn, kind: .function))
@@ -177,20 +186,87 @@ enum SQLCompletionProvider {
177186 }
178187 for field in schemaFields.sorted(by: { $0.name < $1.name })
179188 where field.name.uppercased().hasPrefix(uppercasePrefix) {
180− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
189+ items.append(columnItem(for: field))
181190 }
182191 }
183192
184193 // Don't show the popup when the only match is an exact hit. Compare the
185194 // match text (unquoted), not the display text — a table's display text is
186195 // wrapped in quotes (`"name"`) and would never match the bare prefix.
187− if items.count == 1 && items[0].matchText.uppercased() == uppercasePrefix {
188− return ([], prefix, range)
196+ // Columns are the exception: one that needs quoting but was typed bare
197+ // (`statusCode`, which DataFusion would lowercase) must still be offered,
198+ // so a column only counts as an exact hit when accepting would leave the
199+ // text unchanged (the range already spans any quotes the user typed).
200+ if items.count == 1 {
201+ let only = items[0]
202+ let isExactHit = only.kind == .column
203+ ? nsText.substring(with: range) == only.text
204+ : only.matchText.uppercased() == uppercasePrefix
205+ if isExactHit { return ([], prefix, range) }
189206 }
190207
191208 return (items, prefix, range)
192209 }
193210
211+ /// Whether the UTF-16 `offset` falls inside a `--`/`/* */` comment or a
212+ /// `'...'` string literal. The caret at the very end of an unterminated
213+ /// literal/comment (or of a line comment) is still inside it.
214+ static func isInsideCommentOrStringLiteral(_ text: String, offset: Int) -> Bool {
215+ for token in SQLTokenizer.tokenize(text) {
216+ let range = NSRange(token.range, in: text)
217+ // Tokens are in order; one starting at or after the caret can't contain it.
218+ if range.location >= offset { break }
219+ let end = NSMaxRange(range)
220+ let isClosed: Bool
221+ switch token.kind {
222+ case .lineComment:
223+ isClosed = false
224+ case .blockComment:
225+ isClosed = range.length >= 4 && text[token.range].hasSuffix("*/")
226+ case .stringLiteral:
227+ // `''` escapes pair up, so a terminated literal has an even count.
228+ let quotes = text[token.range].filter { $0 == "'" }.count
229+ isClosed = range.length >= 2 && quotes % 2 == 0
230+ default:
231+ continue
232+ }
233+ if offset < end || (offset == end && !isClosed) { return true }
234+ }
235+ return false
236+ }
237+
238+ /// A column completion, double-quoted when the name wouldn't survive as a bare
239+ /// identifier. DataFusion (Parseable's query engine) folds unquoted identifiers
240+ /// to lowercase, so inserting a mixed-case field bare (`statusCode`) queries a
241+ /// nonexistent `statuscode`; names with dots, spaces, or other punctuation
242+ /// (e.g. OTel's `service.name`) wouldn't parse as one identifier at all. Plain
243+ /// lowercase names stay bare so the common case reads naturally.
244+ static func columnItem(for field: SchemaField) -> SQLCompletionItem {
245+ SQLCompletionItem(
246+ text: quotedIdentifierIfNeeded(field.name),
247+ kind: .column,
248+ detail: field.dataType,
249+ matchText: field.name
250+ )
251+ }
252+
253+ static func quotedIdentifierIfNeeded(_ name: String) -> String {
254+ let isPlainLowercase = name.unicodeScalars.enumerated().allSatisfy { index, scalar in
255+ switch scalar {
256+ case "a"..."z", "_": return true
257+ case "0"..."9": return index > 0
258+ default: return false
259+ }
260+ }
261+ // A bare keyword/function name (`order`, `user`, `limit`, `date`) would be
262+ // parsed as SQL syntax rather than as the column, so quote those too.
263+ let upper = name.uppercased()
264+ let isReservedWord = SQLSyntaxHighlighter.keywords.contains(upper)
265+ || SQLSyntaxHighlighter.functions.contains(upper)
266+ if !name.isEmpty && isPlainLowercase && !isReservedWord { return name }
267+ return QueryViewModel.escapeSQLIdentifier(name)
268+ }
269+
194270 // MARK: - Context Detection
195271
196272 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
@@ -453,6 +529,17 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
453529
454530 // MARK: - NSTableViewDelegate
455531
532+ func tableViewSelectionDidChange(_ notification: Notification) {
533+ // A single click selects a row in the table directly. Keep `selectedIndex`
534+ // — what Return/Tab accept and what the arrow keys move from — in sync,
535+ // otherwise Return inserts the previously highlighted item instead of the
536+ // one the user clicked.
537+ let row = tableView.selectedRow
538+ if row >= 0, row < items.count {
539+ selectedIndex = row
540+ }
541+ }
542+
456543 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
457544 guard row < items.count else { return nil }
458545 let item = items[row]
ParseableViewer/Views/SQLEditorView.swift+41−6
@@ -57,7 +57,7 @@ struct SQLEditorView: NSViewRepresentable {
5757 // Only update when text changed externally (not from user typing)
5858 if textView.string != text && !context.coordinator.isEditing {
5959 let selectedRanges = textView.selectedRanges
60− textView.string = text
60+ context.coordinator.replaceText(with: text, in: textView)
6161 context.coordinator.applyHighlighting(to: textView)
6262 // NSRange is UTF-16 based; compare against the NSString length, not
6363 // String.count (grapheme count), or wide characters drop the selection.
@@ -101,6 +101,11 @@ struct SQLEditorView: NSViewRepresentable {
101101 /// Tracks whether we are inserting a completion so that `textDidChange`
102102 /// doesn't re-trigger the popup for the replacement.
103103 private var isInsertingCompletion = false
104+ /// Set while `replaceText(with:in:)` pushes a model-side change (history
105+ /// pick, saved filter, cell filter, column toggle…) into the text view, so
106+ /// `textDidChange` doesn't echo it back into the binding or open the
107+ /// completion popup for text the user didn't type.
108+ private var isApplyingModelChange = false
104109
105110 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
106111 self.text = text
@@ -116,7 +121,8 @@ struct SQLEditorView: NSViewRepresentable {
116121 // MARK: - NSTextViewDelegate
117122
118123 func textDidChange(_ notification: Notification) {
119− guard let textView = notification.object as? NSTextView else { return }
124+ guard let textView = notification.object as? NSTextView,
125+ !isApplyingModelChange else { return }
120126 isEditing = true
121127 clearErrorHighlight(in: textView)
122128 text.wrappedValue = textView.string
@@ -128,6 +134,34 @@ struct SQLEditorView: NSViewRepresentable {
128134 }
129135 }
130136
137+ /// Replaces the editor's contents with text set from outside the editor.
138+ /// Assigning `textView.string` directly bypasses the text system's undo
139+ /// registration and leaves the user's earlier typing on the undo stack with
140+ /// ranges that belong to the old text — Cmd+Z would then edit an arbitrary
141+ /// span of the new query (or raise on an out-of-range undo). Routing the
142+ /// change through `shouldChangeText`/`didChangeText` records it as one
143+ /// undoable step, so Cmd+Z restores the previous query instead.
144+ func replaceText(with newText: String, in textView: NSTextView) {
145+ let fullRange = NSRange(location: 0, length: (textView.string as NSString).length)
146+ isApplyingModelChange = true
147+ defer { isApplyingModelChange = false }
148+ textView.breakUndoCoalescing()
149+ if textView.shouldChangeText(in: fullRange, replacementString: newText) {
150+ textView.replaceCharacters(in: fullRange, with: newText)
151+ textView.didChangeText()
152+ } else {
153+ textView.string = newText
154+ }
155+ textView.breakUndoCoalescing()
156+ }
157+
158+ func textDidEndEditing(_ notification: Notification) {
159+ // The popup is a separate child panel; without this it keeps floating
160+ // over the results (and accepting double-clicks) after focus moves to
161+ // another control.
162+ completionPopup.hide()
163+ }
164+
131165 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
132166 guard completionPopup.isVisible else { return false }
133167
@@ -223,12 +257,13 @@ struct SQLEditorView: NSViewRepresentable {
223257 }
224258 }
225259
226− // A table completion carries its own quotes; absorb any double-quote the
227− // user typed directly around the word so the inserted `"name"` replaces
228− // it instead of leaving a stray quote. Leading and trailing are handled
260+ // Table and column completions carry their own quoting (a column is
261+ // quoted only when it needs it); absorb any double-quote the user typed
262+ // directly around the word so the inserted identifier replaces it
263+ // instead of leaving a stray quote. Leading and trailing are handled
229264 // independently: a closing quote can exist without an opening one (e.g.
230265 // `FROM my"`), which would otherwise produce a dangling `"name""`.
231− if item.kind == .table {
266+ if item.kind == .table || item.kind == .column {
232267 if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
233268 wordStart -= 1
234269 }
ParseableViewer/Views/SyntaxHighlighting.swift+27−57
@@ -49,9 +49,18 @@ enum SQLSyntaxHighlighter {
4949 static let sortedFunctions: [String] = functions.sorted()
5050
5151 // Pre-compiled regexes (created once, reused every keystroke)
52− private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
53− private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
54− private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
52+ //
53+ // String literals, quoted identifiers, and comments in ONE alternation, so a
54+ // single left-to-right scan claims each span for whichever construct starts
55+ // first — exactly how a SQL lexer reads it. Separate passes can't agree: an
56+ // apostrophe inside a comment (`-- don't`) opened a bogus string that painted
57+ // every following line red, and a `"` inside a string (or any quote inside a
58+ // block comment) did the same. Capture groups: 1 = 'string', 2 = "identifier",
59+ // 3 = `--` line comment or `/* */` block comment (unterminated runs to the end).
60+ private static let literalOrCommentRegex = try! NSRegularExpression(
61+ pattern: "('[^']*(?:''[^']*)*')|(\"[^\"]*(?:\"\"[^\"]*)*\")|(--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|\\z))",
62+ options: []
63+ )
5564 // Matches the same numeric forms as `SQLTokenizer.consumeNumber`: integers,
5665 // decimals, leading-dot floats (`.5`), and exponents (`1e5`, `1.5e-3`). The
5766 // lookbehind keeps digits that belong to an identifier (`col1`, `t.2`) from
@@ -80,20 +89,21 @@ enum SQLSyntaxHighlighter {
8089 let nsText = text as NSString
8190 var protectedRanges: [NSRange] = []
8291
83− // 1. Single-quoted strings
84− applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
85− color: .systemRed, protectedRanges: &protectedRanges)
86−
87− // 2. Double-quoted identifiers
88− applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
89− color: .systemRed, protectedRanges: &protectedRanges)
90−
91− // 3. Comments (-- to end of line). Highlighted AFTER strings, and a `--`
92− // that begins inside a string literal (protectedRanges currently holds
93− // only string ranges) is skipped — otherwise `'a--b' AND y = 5` would
94− // bleed a gray comment over the trailing real SQL.
95− applyCommentRegex(commentRegex, to: textStorage, text: nsText,
96− color: .secondaryLabelColor, protectedRanges: &protectedRanges)
92+ // 1–3. Single-quoted strings, double-quoted identifiers, and comments, in
93+ // one pass so none of them can start inside another (`'a--b'` stays a
94+ // string, `-- don't` stays a comment).
95+ let literalMatches = literalOrCommentRegex.matches(
96+ in: text, range: NSRange(location: 0, length: nsText.length)
97+ )
98+ for match in literalMatches {
99+ let isComment = match.range(at: 3).location != NSNotFound
100+ textStorage.addAttribute(
101+ .foregroundColor,
102+ value: isComment ? NSColor.secondaryLabelColor : NSColor.systemRed,
103+ range: match.range
104+ )
105+ protectedRanges.append(match.range)
106+ }
97107
98108 // 4. Numbers (outside protected ranges)
99109 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
@@ -110,46 +120,6 @@ enum SQLSyntaxHighlighter {
110120 protectedRanges: protectedRanges)
111121 }
112122
113− private static func applyRegex(
114− _ regex: NSRegularExpression,
115− to textStorage: NSTextStorage,
116− text: NSString,
117− color: NSColor,
118− protectedRanges: inout [NSRange]
119− ) {
120− let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
121− for match in matches {
122− textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
123− protectedRanges.append(match.range)
124− }
125− }
126−
127− /// Like `applyRegex`, but skips any match whose start falls inside an
128− /// already-claimed range (used for comments so a `--` inside a string literal
129− /// doesn't start a comment).
130− private static func applyCommentRegex(
131− _ regex: NSRegularExpression,
132− to textStorage: NSTextStorage,
133− text: NSString,
134− color: NSColor,
135− protectedRanges: inout [NSRange]
136− ) {
137− let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
138− for match in matches where !isStartProtected(match.range.location, by: protectedRanges) {
139− textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
140− protectedRanges.append(match.range)
141− }
142− }
143−
144− private static func isStartProtected(_ location: Int, by protectedRanges: [NSRange]) -> Bool {
145− for protected in protectedRanges {
146− if location >= protected.location && location < NSMaxRange(protected) {
147− return true
148− }
149− }
150− return false
151− }
152−
153123 private static func applyUnprotectedRegex(
154124 _ regex: NSRegularExpression,
155125 to textStorage: NSTextStorage,
ParseableViewerTests/QueryViewModelTests.swift+122−2
@@ -235,7 +235,7 @@ final class QueryViewModelTests: XCTestCase {
235235 let vm = QueryViewModel()
236236 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
237237 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
238− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'debug'"))
238+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'debug'"))
239239 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
240240 }
241241
@@ -288,7 +288,7 @@ final class QueryViewModelTests: XCTestCase {
288288 let vm = QueryViewModel()
289289 vm.sqlQuery = "SELECT * FROM \"logs\""
290290 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
291− XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'warn'"))
291+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" IS DISTINCT FROM 'warn'"))
292292 }
293293
294294 func testAddColumnFilterEscapesSingleQuotesInValue() {
@@ -671,5 +671,125 @@ final class QueryViewModelTests: XCTestCase {
671671 let empty = vm.emptyColumns(in: records, columns: ["a"])
672672 XCTAssertTrue(empty.isEmpty)
673673 }
674+
675+ // MARK: - Column filter edge cases
676+
677+ func testAddColumnFilterKeepsTrailingSemicolonLast() {
678+ let vm = QueryViewModel()
679+ vm.sqlQuery = "SELECT * FROM \"logs\";"
680+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
681+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\" WHERE \"level\" = 'error';")
682+ }
683+
684+ func testAddColumnFilterDoesNotLandInTrailingComment() {
685+ let vm = QueryViewModel()
686+ vm.sqlQuery = "SELECT * FROM \"logs\" -- recent"
687+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
688+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\" WHERE \"level\" = 'error' -- recent")
689+ }
690+
691+ func testAddColumnFilterKeepsLineBreakAfterWhereBodyComment() {
692+ let vm = QueryViewModel()
693+ vm.sqlQuery = "SELECT * FROM \"logs\"\nWHERE a = 1 -- note\nORDER BY p_timestamp DESC"
694+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
695+ XCTAssertEqual(
696+ vm.sqlQuery,
697+ "SELECT * FROM \"logs\"\nWHERE a = 1 AND \"host\" = 'web-1' -- note\nORDER BY p_timestamp DESC"
698+ )
699+ }
700+
701+ func testAddColumnFilterWithTrailingClauseBeforeWhereDoesNotCrash() {
702+ let vm = QueryViewModel()
703+ vm.sqlQuery = "SELECT a FROM t1 GROUP BY a UNION ALL SELECT a FROM t2 WHERE b = 1"
704+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
705+ XCTAssertEqual(
706+ vm.sqlQuery,
707+ "SELECT a FROM t1 GROUP BY a UNION ALL SELECT a FROM t2 WHERE b = 1 AND \"host\" = 'web-1'"
708+ )
709+ }
710+
711+ func testAddColumnFilterIgnoresKeywordNamedColumnsInSelectList() {
712+ let vm = QueryViewModel()
713+ vm.sqlQuery = "SELECT offset, message FROM \"kafka\" WHERE partition = 3"
714+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
715+ XCTAssertEqual(
716+ vm.sqlQuery,
717+ "SELECT offset, message FROM \"kafka\" WHERE partition = 3 AND \"host\" = 'web-1'"
718+ )
719+ }
720+
721+ // MARK: - SELECT list rewriting
722+
723+ func testUpdateSQLColumnsLeavesExpressionListsAlone() {
724+ let vm = QueryViewModel()
725+ let sql = "SELECT count(*) AS n, level FROM \"logs\" GROUP BY level"
726+ vm.sqlQuery = sql
727+ vm.columns = ["n", "level"]
728+ vm.columnOrder = ["n", "level"]
729+ vm.toggleColumnVisibility("n")
730+ XCTAssertEqual(vm.sqlQuery, sql)
731+ XCTAssertEqual(vm.visibleColumns, ["level"])
732+ }
733+
734+ func testUpdateSQLColumnsDoesNotBakeInAutoHiddenColumns() {
735+ let vm = QueryViewModel()
736+ vm.sqlQuery = "SELECT * FROM \"logs\""
737+ vm.columns = ["a", "b", "c"]
738+ vm.columnOrder = ["a", "b", "c"]
739+ vm.autoHiddenColumns = ["c"]
740+ vm.moveColumn("b", to: "a")
741+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
742+ }
743+
744+ func testUpdateSQLColumnsKeepsHiddenColumnOutAfterNarrowedRerun() {
745+ // Hiding `c` narrowed the SELECT list, so the re-run's results lack it; a
746+ // later reorder must not switch back to `*` and fetch it again.
747+ let vm = QueryViewModel()
748+ vm.sqlQuery = "SELECT \"a\", \"b\" FROM \"logs\""
749+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "c"], hidden: ["c"])
750+ vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: nil)
751+ vm.moveColumn("b", to: "a")
752+ XCTAssertEqual(vm.sqlQuery, "SELECT \"b\", \"a\" FROM \"logs\"")
753+ XCTAssertTrue(vm.hasHiddenColumns)
754+ vm.showAllColumns()
755+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
756+ }
757+
758+ func testExcludeFilterKeepsRowsWhereColumnIsNull() {
759+ let vm = QueryViewModel()
760+ vm.sqlQuery = "SELECT * FROM \"logs\""
761+ vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
762+ XCTAssertFalse(vm.sqlQuery.contains("<>"), "`<>` would also drop rows where level is NULL")
763+ XCTAssertTrue(vm.sqlQuery.contains("\"level\" IS DISTINCT FROM 'debug'"))
764+ }
765+
766+ func testUpdateSQLColumnsKeepsListedColumnsMissingFromResults() {
767+ // Parseable omits all-null fields from query JSON, so a listed column can
768+ // be absent from `columns`; rewriting must neither refuse nor drop it.
769+ let vm = QueryViewModel()
770+ vm.sqlQuery = "SELECT \"a\", \"b\", \"c\", \"sparse\" FROM \"logs\""
771+ vm.columns = ["a", "b", "c"]
772+ vm.columnOrder = ["a", "b", "c"]
773+ vm.toggleColumnVisibility("c")
774+ XCTAssertEqual(vm.sqlQuery, "SELECT \"a\", \"b\", \"sparse\" FROM \"logs\"")
775+ }
776+
777+ func testReorderKeepsStarWhenSavedHiddenColumnIsAbsentAnyway() {
778+ // Under `SELECT *` a saved-hidden column that's missing is missing for
779+ // other reasons (sparse/removed); a plain reorder must keep `*`.
780+ let vm = QueryViewModel()
781+ vm.sqlQuery = "SELECT * FROM \"logs\""
782+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "gone"], hidden: ["gone"])
783+ vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: nil)
784+ vm.moveColumn("b", to: "a")
785+ XCTAssertEqual(vm.sqlQuery, "SELECT * FROM \"logs\"")
786+ }
787+
788+ func testMergedColumnOrderKeepsSavedPositionForAbsentColumn() {
789+ XCTAssertEqual(
790+ QueryViewModel.mergedColumnOrder(["b", "a"], inserting: ["x"], reference: ["a", "x", "b"]),
791+ ["b", "a", "x"]
792+ )
793+ }
674794}
675795
ParseableViewerTests/SQLAutoCompleteTests.swift+56
@@ -275,6 +275,62 @@ final class SQLAutoCompleteTests: XCTestCase {
275275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276276 XCTAssertEqual(ctx, .tableRef)
277277 }
278+
279+ // MARK: - Identifier quoting / non-SQL regions
280+
281+ func testColumnCompletionQuotesNamesThatNeedIt() {
282+ let fields = [SchemaField(name: "statusCode", dataType: "Int64"), SchemaField(name: "status", dataType: "Utf8")]
283+ let result = SQLCompletionProvider.completions(
284+ for: "SELECT * FROM t WHERE stat", cursorPosition: 26,
285+ streamNames: [], schemaFields: fields
286+ )
287+ let columns = result.items.filter { $0.kind == .column }.map(\.text)
288+ XCTAssertEqual(columns, ["status", "\"statusCode\""])
289+ }
290+
291+ func testNoCompletionsInsideLineComment() {
292+ let text = "-- grouped by date"
293+ let result = SQLCompletionProvider.completions(
294+ for: text, cursorPosition: (text as NSString).length,
295+ streamNames: streams, schemaFields: fields
296+ )
297+ XCTAssertTrue(result.items.isEmpty)
298+ }
299+
300+ func testNoCompletionsInsideStringLiteral() {
301+ let text = "SELECT * FROM t WHERE msg LIKE '%sel"
302+ let result = SQLCompletionProvider.completions(
303+ for: text, cursorPosition: (text as NSString).length,
304+ streamNames: streams, schemaFields: fields
305+ )
306+ XCTAssertTrue(result.items.isEmpty)
307+ }
308+
309+ func testFullyTypedMixedCaseColumnStillOffersQuotedForm() {
310+ let fields = [SchemaField(name: "statusCode", dataType: "Int64")]
311+ let bare = "SELECT * FROM t WHERE statusCode"
312+ let result = SQLCompletionProvider.completions(
313+ for: bare, cursorPosition: (bare as NSString).length,
314+ streamNames: [], schemaFields: fields
315+ )
316+ XCTAssertEqual(result.items.map(\.text), ["\"statusCode\""])
317+
318+ // Already quoted: accepting would change nothing, so no popup.
319+ let quoted = "SELECT * FROM t WHERE \"statusCode\""
320+ let quotedResult = SQLCompletionProvider.completions(
321+ for: quoted, cursorPosition: (quoted as NSString).length - 1,
322+ streamNames: [], schemaFields: fields
323+ )
324+ XCTAssertTrue(quotedResult.items.isEmpty)
325+ }
326+
327+ func testReservedWordColumnIsQuoted() {
328+ let fields = [SchemaField(name: "order", dataType: "Utf8")]
329+ let result = SQLCompletionProvider.completions(
330+ for: "SELECT ord", cursorPosition: 10, streamNames: [], schemaFields: fields
331+ )
332+ XCTAssertEqual(result.items.filter { $0.kind == .column }.map(\.text), ["\"order\""])
333+ }
278334}
279335
280336// Make SQLCompletionContext Equatable for assertions
ParseableViewerTests/SQLTokenizerTests.swift+15
@@ -348,4 +348,19 @@ final class SQLTokenizerTests: XCTestCase {
348348 let sql = "SELECT * FROM t"
349349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
350350 }
351+
352+ // MARK: - CRLF line endings
353+
354+ func testCRLFIsWhitespaceAndEndsLineComment() {
355+ // Swift treats "\r\n" as a single Character, equal to neither "\r" nor "\n".
356+ let sql = "SELECT * -- all\r\nFROM t"
357+ let kinds = SQLTokenizer.tokenize(sql).map(\.kind).filter { !$0.isTrivia }
358+ XCTAssertEqual(kinds, [.keyword("SELECT"), .star, .keyword("FROM"), .identifier("t")])
359+ }
360+
361+ func testSelectColumnListRangeExcludesCRLF() {
362+ let sql = "SELECT *\r\nFROM t"
363+ let range = SQLTokenizer.selectColumnListRange(in: sql)
364+ XCTAssertEqual(range.map { String(sql[$0]) }, "*")
365+ }
351366}
ParseableViewerTests/SyntaxHighlightingTests.swift+10
@@ -140,6 +140,16 @@ final class SyntaxHighlightingTests: XCTestCase {
140140 "Double-quoted identifiers should differ from keywords")
141141 }
142142
143+ func testSQLApostropheInCommentDoesNotStartString() {
144+ // The later quoted literal gives the stray apostrophe a closing partner, so
145+ // separate string/comment passes painted everything in between as a string.
146+ let sql = "-- don't\nSELECT * FROM t WHERE a = 'b'"
147+ let ts = sqlHighlight(sql)
148+ let selectIdx = (sql as NSString).range(of: "SELECT").location
149+ XCTAssertEqual(colorAt(ts, selectIdx), colorAt(sqlHighlight("SELECT x"), 0),
150+ "An apostrophe inside a comment must not open a string literal")
151+ }
152+
143153 // MARK: - JSON Highlighting
144154
145155 func testJSONHighlightProducesOutput() {