git.iko.soy/parseable-macos
on master help sign in
Fix query editor and column filter bugs, including a crash
c040e74Claude
ParseableViewer/Models/SQLTokenizer.swift+18−4
@@ -80,8 +80,8 @@ struct SQLTokenizer: Sendable {
80
80
81 switch c {
81 switch c {
82 // Whitespace
82 // Whitespace
83− case " ", "\t", "\n", "\r":
83+ case _ where isWhitespace(c):
84− advance(&i, in: sql, while: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
84+ advance(&i, in: sql, while: isWhitespace)
85 tokens.append(Token(kind: .whitespace, range: start..<i))
85 tokens.append(Token(kind: .whitespace, range: start..<i))
86
86
87 // Line comment
87 // Line comment
@@ -89,8 +89,11 @@ struct SQLTokenizer: Sendable {
89 // Stop at CR as well as LF: with CRLF (`\r\n`) line endings, consuming
89 // Stop at CR as well as LF: with CRLF (`\r\n`) line endings, consuming
90 // the trailing `\r` into the comment token would push its boundary past
90 // the trailing `\r` into the comment token would push its boundary past
91 // where `characterOffset`/DataFusion treat the line as ending, shifting
91 // where `characterOffset`/DataFusion treat the line as ending, shifting
92− // the server-error highlight range. `\r` is then tokenized as whitespace.
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.
93− advance(&i, in: sql, while: { $0 != "\n" && $0 != "\r" })
96+ advance(&i, in: sql, while: { !isLineBreak($0) })
94 tokens.append(Token(kind: .lineComment, range: start..<i))
97 tokens.append(Token(kind: .lineComment, range: start..<i))
95
98
96 // Block comment
99 // Block comment
@@ -259,6 +262,17 @@ struct SQLTokenizer: Sendable {
259 return value // unterminated
262 return value // unterminated
260 }
263 }
261
264
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+
262 /// SQL number literals use only ASCII digits. `Character.isNumber` also
276 /// SQL number literals use only ASCII digits. `Character.isNumber` also
263 /// matches Unicode numerics (`²`, `½`, Arabic-Indic digits, etc.), which
277 /// matches Unicode numerics (`²`, `½`, Arabic-Indic digits, etc.), which
264 /// DataFusion's lexer rejects — accepting them here would tokenize those
278 /// DataFusion's lexer rejects — accepting them here would tokenize those
ParseableViewer/ViewModels/QueryViewModel.swift+248−49
@@ -59,9 +59,29 @@ final class QueryViewModel {
59 var columns: [String] = []
59 var columns: [String] = []
60 var columnOrder: [String] = []
60 var columnOrder: [String] = []
61 var hiddenColumns: Set<String> = []
61 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] = []
62 var autoHiddenColumns: Set<String> = []
72 var autoHiddenColumns: Set<String> = []
63 /// Column config from a saved query, applied on next query execution.
73 /// Column config from a saved query, applied on next query execution.
64 var pendingColumnConfig: ColumnConfiguration?
74 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?
65 private var currentStream: String?
85 private var currentStream: String?
66 var isLoading = false
86 var isLoading = false
67 var errorMessage: String?
87 var errorMessage: String?
@@ -192,9 +212,16 @@ final class QueryViewModel {
192 return columnOrder.filter { !allHidden.contains($0) }
212 return columnOrder.filter { !allHidden.contains($0) }
193 }
213 }
194
214
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+
195 func toggleColumnVisibility(_ column: String) {
221 func toggleColumnVisibility(_ column: String) {
196 if hiddenColumns.contains(column) {
222 if hiddenColumns.contains(column) {
197 hiddenColumns.remove(column)
223 hiddenColumns.remove(column)
224+ savedHiddenColumns.remove(column)
198 } else if autoHiddenColumns.contains(column) {
225 } else if autoHiddenColumns.contains(column) {
199 autoHiddenColumns.remove(column)
226 autoHiddenColumns.remove(column)
200 } else {
227 } else {
@@ -203,6 +230,7 @@ final class QueryViewModel {
203 let visibleCount = columnOrder.count - allHidden.count
230 let visibleCount = columnOrder.count - allHidden.count
204 if visibleCount > 1 {
231 if visibleCount > 1 {
205 hiddenColumns.insert(column)
232 hiddenColumns.insert(column)
233+ savedHiddenColumns.insert(column)
206 }
234 }
207 }
235 }
208 saveColumnConfig()
236 saveColumnConfig()
@@ -211,6 +239,7 @@ final class QueryViewModel {
211
239
212 func showAllColumns() {
240 func showAllColumns() {
213 hiddenColumns.removeAll()
241 hiddenColumns.removeAll()
242+ savedHiddenColumns.removeAll()
214 autoHiddenColumns.removeAll()
243 autoHiddenColumns.removeAll()
215 saveColumnConfig()
244 saveColumnConfig()
216 updateSQLColumns()
245 updateSQLColumns()
@@ -240,32 +269,116 @@ final class QueryViewModel {
240
269
241 func resetColumnConfig() {
270 func resetColumnConfig() {
242 columnOrder = columns
271 columnOrder = columns
272+ savedColumnOrder = []
243 hiddenColumns.removeAll()
273 hiddenColumns.removeAll()
274+ savedHiddenColumns.removeAll()
244 autoHiddenColumns.removeAll()
275 autoHiddenColumns.removeAll()
245 saveColumnConfig()
276 saveColumnConfig()
246 updateSQLColumns()
277 updateSQLColumns()
247 }
278 }
248
279
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).
250 func updateSQLColumns() {
282 func updateSQLColumns() {
251− guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery) else { return }
283+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
252−
284+ let currentList = Self.parseColumnList(sqlQuery[range]) else { return }
253− let visible = visibleColumns
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) }
254 guard !visible.isEmpty else { return }
291 guard !visible.isEmpty else { return }
255
292
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+
256 let newColumnList: String
307 let newColumnList: String
257 // Use `*` whenever every column is visible, regardless of display order —
308 // Use `*` whenever every column is visible, regardless of display order —
258 // reordering columns (a set-preserving permutation) must not pin the query
309 // reordering columns (a set-preserving permutation) must not pin the query
259− // to an explicit snapshot that hides newly-appearing fields on re-query.
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.
260− if visible.count == columns.count && Set(visible) == Set(columns) {
313+ if !excludesHiddenColumn && visible.count == columns.count && Set(visible) == Set(columns) {
261 newColumnList = "*"
314 newColumnList = "*"
262 } else {
315 } else {
263− newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
316+ newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
264 }
317 }
265
318
266 sqlQuery.replaceSubrange(range, with: newColumnList)
319 sqlQuery.replaceSubrange(range, with: newColumnList)
267 }
320 }
268
321
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+
269 // MARK: - Column Configuration Persistence
382 // MARK: - Column Configuration Persistence
270
383
271 struct ColumnConfiguration: Codable {
384 struct ColumnConfiguration: Codable {
@@ -279,7 +392,16 @@ final class QueryViewModel {
279
392
280 private func saveColumnConfig() {
393 private func saveColumnConfig() {
281 guard let stream = currentStream else { return }
394 guard let stream = currentStream else { return }
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
282− let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
404+ let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
283 if let data = try? JSONEncoder().encode(config) {
405 if let data = try? JSONEncoder().encode(config) {
284 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
406 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
285 }
407 }
@@ -308,7 +430,9 @@ final class QueryViewModel {
308
430
309 guard let config else {
431 guard let config else {
310 columnOrder = extractedColumns
432 columnOrder = extractedColumns
433+ savedColumnOrder = []
311 hiddenColumns = []
434 hiddenColumns = []
435+ savedHiddenColumns = []
312 return
436 return
313 }
437 }
314
438
@@ -321,6 +445,8 @@ final class QueryViewModel {
321 merged.append(col)
445 merged.append(col)
322 }
446 }
323 columnOrder = merged
447 columnOrder = merged
448+ savedColumnOrder = config.order
449+ savedHiddenColumns = config.hidden
324 hiddenColumns = config.hidden.intersection(extractedSet)
450 hiddenColumns = config.hidden.intersection(extractedSet)
325
451
326 // The `visibleCount > 1` guard in toggleColumnVisibility only covers
452 // The `visibleCount > 1` guard in toggleColumnVisibility only covers
@@ -519,6 +645,8 @@ final class QueryViewModel {
519 // report stale column names after the failure.
645 // report stale column names after the failure.
520 columnOrder = []
646 columnOrder = []
521 hiddenColumns = []
647 hiddenColumns = []
648+ savedHiddenColumns = []
649+ savedColumnOrder = []
522 autoHiddenColumns = []
650 autoHiddenColumns = []
523 }
651 }
524 }
652 }
@@ -685,6 +813,8 @@ final class QueryViewModel {
685 columns = []
813 columns = []
686 columnOrder = []
814 columnOrder = []
687 hiddenColumns = []
815 hiddenColumns = []
816+ savedHiddenColumns = []
817+ savedColumnOrder = []
688 autoHiddenColumns = []
818 autoHiddenColumns = []
689 currentStream = nil
819 currentStream = nil
690 resultCount = 0
820 resultCount = 0
@@ -698,6 +828,7 @@ final class QueryViewModel {
698 // a later reload guard never sees a non-nil stream paired with empty data.
828 // a later reload guard never sees a non-nil stream paired with empty data.
699 latestSchemaStream = nil
829 latestSchemaStream = nil
700 latestQueryStream = nil
830 latestQueryStream = nil
831+ boundContext = nil
701 }
832 }
702
833
703 /// Modifies `sqlQuery` to add a column-value filter condition.
834 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -707,75 +838,139 @@ final class QueryViewModel {
707 let condition: String
838 let condition: String
708 if let value, value != .null {
839 if let value, value != .null {
709 let literal = value.sqlLiteral
840 let literal = value.sqlLiteral
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)"
710− condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
847+ : "\(escapedCol) = \(literal)"
711 } else {
848 } else {
712 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
849 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
713 }
850 }
714
851
715− var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
852+ let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
716 if sql.isEmpty { return }
853 if sql.isEmpty { return }
717
854
718 // Locate the top-level WHERE and the first trailing clause using the
855 // Locate the top-level WHERE and the first trailing clause using the
719 // tokenizer rather than a raw regex, so keywords inside quoted
856 // tokenizer rather than a raw regex, so keywords inside quoted
720 // identifiers, string literals, comments, or subqueries are ignored.
857 // identifiers, string literals, comments, or subqueries are ignored.
721− let tailKeywords: Set<String> = ["ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET"]
858+ let tokens = SQLTokenizer.tokenize(sql)
722 var depth = 0
859 var depth = 0
723− var whereEnd: String.Index?
860+ // WHERE and the trailing clauses only follow the top-level FROM. Keywords
724− var tailStart: String.Index?
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?
725 // Track whether the existing WHERE body has a top-level OR — only then does
867 // Track whether the existing WHERE body has a top-level OR — only then does
726 // AND-ing a new condition need parentheses to preserve precedence.
868 // AND-ing a new condition need parentheses to preserve precedence.
727 var bodyHasTopLevelOr = false
869 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 }
729 switch token.kind {
902 switch token.kind {
730 case .leftParen:
903 case .leftParen:
731 depth += 1
904 depth += 1
732 case .rightParen:
905 case .rightParen:
733 depth = max(0, depth - 1)
906 depth = max(0, depth - 1)
907+ case .keyword("FROM") where depth == 0:
908+ fromSeen = true
734− case .keyword(let kw) where depth == 0:
909+ case .keyword(let kw) where depth == 0 && fromSeen:
735− if kw == "WHERE", whereEnd == nil {
910+ if kw == "WHERE", whereIndex == nil {
736− whereEnd = token.range.upperBound
911+ whereIndex = idx
737− } else if tailKeywords.contains(kw), tailStart == nil {
912+ // A trailing clause seen *before* the WHERE (e.g. the GROUP BY of
738− tailStart = token.range.lowerBound
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
739− } else if kw == "OR", whereEnd != nil, tailStart == nil {
920+ } else if kw == "OR", whereIndex != nil, tailIndex == nil {
740 bodyHasTopLevelOr = true
921 bodyHasTopLevelOr = true
741 }
922 }
742 default:
923 default:
743 break
924 break
744 }
925 }
745 }
926 }
746−
927+ // Nothing but comments/semicolons — there's no statement to filter.
747− if let whereEnd {
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]) }) {
748− // Parenthesize the existing WHERE body before AND-ing the new
947+ // Parenthesize the existing WHERE body before AND-ing the new
749− // condition only when it has a top-level OR, otherwise the OR
948+ // condition only when it has a top-level OR, otherwise the OR
750− // silently changes meaning: `WHERE a = 1 OR b = 2` would become
949+ // 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
950+ // `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
951+ // `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
952+ // `(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.
953+ // left un-parenthesized so simple filters stay readable.
755− let bodyEnd = tailStart ?? sql.endIndex
954+ let bodyStart = tokens[first].range.lowerBound
756− let head = sql[..<whereEnd]
955+ let bodyEnd = tokens[last].range.upperBound
757− let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
956+ let body = sql[bodyStart..<bodyEnd]
758− let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
957+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : String(body)
759− // An empty WHERE body (e.g. a half-written `... WHERE` with no
958+ sqlQuery = "\(sql[..<bodyStart])\(wrappedBody) AND \(condition)\(separated(sql[bodyEnd...]))"
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)"
765 } else {
959 } else {
766− let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
960+ // An empty WHERE body (e.g. a half-written `... WHERE` with no
767− sql = "\(head) \(wrappedBody) AND \(condition)"
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...]))"
768 }
966 }
769− if !tail.isEmpty {
770− sql += " \(tail)"
771− }
772− } else if let tailStart {
967+ } else if let tailIndex {
968+ let tailStart = tokens[tailIndex].range.lowerBound
773− sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
969+ sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
774 } else {
970 } else {
775− sql += " WHERE \(condition)"
971+ let statementEnd = tokens[lastSignificant].range.upperBound
972+ sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
776 }
973 }
777−
778− sqlQuery = sql
779 }
974 }
780
975
781 /// Sets the default query for the given stream, returning `true` if the
976 /// Sets the default query for the given stream, returning `true` if the
@@ -819,6 +1014,10 @@ final class QueryViewModel {
819 // MARK: - Query History
1014 // MARK: - Query History
820
1015
821 private func addToHistory(_ entry: QueryHistoryEntry) {
1016 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()
822 // Deduplicate consecutive identical queries
1021 // Deduplicate consecutive identical queries
823 if queryHistory.first?.sql == entry.sql { return }
1022 if queryHistory.first?.sql == entry.sql { return }
824 queryHistory.insert(entry, at: 0)
1023 queryHistory.insert(entry, at: 0)
ParseableViewer/Views/ContentView.swift+26−1
@@ -31,6 +31,18 @@ struct ContentView: View {
31
31
32struct MainContentView: View {
32struct MainContentView: View {
33 @Environment(AppState.self) private var appState
33 @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+ }
34
46
35 var body: some View {
47 var body: some View {
36 @Bindable var appState = appState
48 @Bindable var appState = appState
@@ -63,7 +75,7 @@ struct MainContentView: View {
63 } else {
75 } else {
64 switch appState.currentTab {
76 switch appState.currentTab {
65 case .query:
77 case .query:
66− QueryView()
78+ QueryView(viewModel: queryViewModel)
67 .id(AppState.AppTab.query)
79 .id(AppState.AppTab.query)
68 case .liveTail:
80 case .liveTail:
69 LiveTailView()
81 LiveTailView()
@@ -83,6 +95,19 @@ struct MainContentView: View {
83 }
95 }
84 }
96 }
85 }
97 }
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+ }
86 }
111 }
87}
112}
88
113
ParseableViewer/Views/QueryView.swift+43−9
@@ -12,7 +12,11 @@ private struct QueryViewContext: Equatable {
12
12
13struct QueryView: View {
13struct QueryView: View {
14 @Environment(AppState.self) private var appState
14 @Environment(AppState.self) private var appState
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.
15− @State private var viewModel = QueryViewModel()
19+ let viewModel: QueryViewModel
16 @State private var showSaveFilterSheet = false
20 @State private var showSaveFilterSheet = false
17 @State private var saveFilterName = ""
21 @State private var saveFilterName = ""
18 @State private var isSavingFilter = false
22 @State private var isSavingFilter = false
@@ -247,6 +251,23 @@ struct QueryView: View {
247 isLoading: viewModel.isLoading,
251 isLoading: viewModel.isLoading,
248 wrapText: wrapText,
252 wrapText: wrapText,
249 onCellFilter: { column, value, exclude in
253 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+ }
250 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
271 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
251 Task {
272 Task {
252 await viewModel.executeQuery(
273 await viewModel.executeQuery(
@@ -269,6 +290,9 @@ struct QueryView: View {
269 let streamChanged = old.stream != new.stream
290 let streamChanged = old.stream != new.stream
270 if streamChanged {
291 if streamChanged {
271 viewModel.clearResults()
292 viewModel.clearResults()
293+ viewModel.boundContext = QueryViewModel.BoundContext(
294+ connectionID: appState.activeConnection?.id, stream: stream
295+ )
272 Task {
296 Task {
273 await viewModel.loadSchema(client: appState.client, stream: stream)
297 await viewModel.loadSchema(client: appState.client, stream: stream)
274 }
298 }
@@ -303,6 +327,21 @@ struct QueryView: View {
303 }
327 }
304 .onAppear {
328 .onAppear {
305 if let stream = appState.selectedStream {
329 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
306 // Reload the schema when it's missing OR when the loaded schema
345 // Reload the schema when it's missing OR when the loaded schema
307 // belongs to a different stream than the current one — otherwise a
346 // belongs to a different stream than the current one — otherwise a
308 // viewModel that survives with another stream's schema would keep
347 // viewModel that survives with another stream's schema would keep
@@ -328,7 +367,7 @@ struct QueryView: View {
328 )
367 )
329 }
368 }
330 } else {
369 } else {
331− let didSetDefault = viewModel.setDefaultQuery(stream: stream)
370+ let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: previousStream)
332 if didSetDefault && viewModel.results.isEmpty {
371 if didSetDefault && viewModel.results.isEmpty {
333 Task {
372 Task {
334 await viewModel.executeQuery(
373 await viewModel.executeQuery(
@@ -340,11 +379,6 @@ struct QueryView: View {
340 }
379 }
341 }
380 }
342 }
381 }
343− .onDisappear {
344− if viewModel.isLoading {
345− viewModel.cancelQuery()
346− }
347− }
348 .onChange(of: appState.queryRefreshToken) { _, _ in
382 .onChange(of: appState.queryRefreshToken) { _, _ in
349 // Guard on a selected stream like every sibling query trigger in this
383 // Guard on a selected stream like every sibling query trigger in this
350 // view. Without it, a refresh (Cmd+R) with no stream selected and an
384 // view. Without it, a refresh (Cmd+R) with no stream selected and an
@@ -462,7 +496,7 @@ struct ColumnManagerView: View {
462 Text("Columns")
496 Text("Columns")
463 .font(.headline)
497 .font(.headline)
464 Spacer()
498 Spacer()
465− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
499+ if viewModel.hasHiddenColumns || viewModel.columnOrder != viewModel.columns {
466 Button("Reset") {
500 Button("Reset") {
467 viewModel.resetColumnConfig()
501 viewModel.resetColumnConfig()
468 }
502 }
@@ -516,7 +550,7 @@ struct ColumnManagerView: View {
516 }
550 }
517 .listStyle(.plain)
551 .listStyle(.plain)
518
552
519− if !viewModel.hiddenColumns.isEmpty || !viewModel.autoHiddenColumns.isEmpty {
553+ if viewModel.hasHiddenColumns {
520 Divider()
554 Divider()
521 Button("Show All Columns") {
555 Button("Show All Columns") {
522 viewModel.showAllColumns()
556 viewModel.showAllColumns()
ParseableViewer/Views/SQLAutoComplete.swift+98−11
@@ -105,23 +105,32 @@ enum SQLCompletionProvider {
105 return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
105 return ([], "", NSRange(location: wordStart, length: wordEnd - wordStart))
106 }
106 }
107
107
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+
108 let context = determineContext(text: text, position: wordStart)
116 let context = determineContext(text: text, position: wordStart)
109
117
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
111 // already typed quotes directly around the word (common for stream names
119 // already typed quotes directly around the word (common for stream names
112 // with spaces, e.g. `FROM "my"`), absorb them into the replacement range so
120 // with spaces, e.g. `FROM "my"`), absorb them into the replacement range so
113 // the inserted `"name"` replaces the stray quotes instead of producing
121 // the inserted `"name"` replaces the stray quotes instead of producing
114 // `""name""`. Mirror `insertCompletion`, which absorbs leading and trailing
122 // `""name""`. Mirror `insertCompletion`, which absorbs leading and trailing
115− // quotes independently for any `.table` item — so apply this wherever table
123+ // quotes independently for any `.table`/`.column` item — so apply this
116− // completions are offered (`.tableRef` and `.general`), not just `.tableRef`.
124+ // wherever those completions are offered (`.tableRef`, `.columnRef` and
125+ // `.general`).
117 var replaceStart = wordStart
126 var replaceStart = wordStart
118 var replaceEnd = wordEnd
127 var replaceEnd = wordEnd
119− let offersTable: Bool
128+ let offersIdentifiers: Bool
120 switch context {
129 switch context {
121− case .tableRef, .general: offersTable = true
130+ case .tableRef, .columnRef, .general: offersIdentifiers = true
122− default: offersTable = false
131+ case .afterOrder, .afterGroup: offersIdentifiers = false
123 }
132 }
124− if offersTable {
133+ if offersIdentifiers {
125 if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
134 if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
126 replaceStart -= 1
135 replaceStart -= 1
127 }
136 }
@@ -147,7 +156,7 @@ enum SQLCompletionProvider {
147 case .columnRef:
156 case .columnRef:
148 for field in schemaFields.sorted(by: { $0.name < $1.name })
157 for field in schemaFields.sorted(by: { $0.name < $1.name })
149 where field.name.uppercased().hasPrefix(uppercasePrefix) {
158 where field.name.uppercased().hasPrefix(uppercasePrefix) {
150− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
159+ items.append(columnItem(for: field))
151 }
160 }
152 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
161 for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
153 items.append(SQLCompletionItem(text: fn, kind: .function))
162 items.append(SQLCompletionItem(text: fn, kind: .function))
@@ -177,20 +186,87 @@ enum SQLCompletionProvider {
177 }
186 }
178 for field in schemaFields.sorted(by: { $0.name < $1.name })
187 for field in schemaFields.sorted(by: { $0.name < $1.name })
179 where field.name.uppercased().hasPrefix(uppercasePrefix) {
188 where field.name.uppercased().hasPrefix(uppercasePrefix) {
180− items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
189+ items.append(columnItem(for: field))
181 }
190 }
182 }
191 }
183
192
184 // Don't show the popup when the only match is an exact hit. Compare the
193 // Don't show the popup when the only match is an exact hit. Compare the
185 // match text (unquoted), not the display text — a table's display text is
194 // match text (unquoted), not the display text — a table's display text is
186 // wrapped in quotes (`"name"`) and would never match the bare prefix.
195 // wrapped in quotes (`"name"`) and would never match the bare prefix.
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
187− if items.count == 1 && items[0].matchText.uppercased() == uppercasePrefix {
204+ : only.matchText.uppercased() == uppercasePrefix
188− return ([], prefix, range)
205+ if isExactHit { return ([], prefix, range) }
189 }
206 }
190
207
191 return (items, prefix, range)
208 return (items, prefix, range)
192 }
209 }
193
210
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+
194 // MARK: - Context Detection
270 // MARK: - Context Detection
195
271
196 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
272 static func determineContext(text: String, position: Int) -> SQLCompletionContext {
@@ -453,6 +529,17 @@ final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDele
453
529
454 // MARK: - NSTableViewDelegate
530 // MARK: - NSTableViewDelegate
455
531
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+
456 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
543 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
457 guard row < items.count else { return nil }
544 guard row < items.count else { return nil }
458 let item = items[row]
545 let item = items[row]
ParseableViewer/Views/SQLEditorView.swift+41−6
@@ -57,7 +57,7 @@ struct SQLEditorView: NSViewRepresentable {
57 // Only update when text changed externally (not from user typing)
57 // Only update when text changed externally (not from user typing)
58 if textView.string != text && !context.coordinator.isEditing {
58 if textView.string != text && !context.coordinator.isEditing {
59 let selectedRanges = textView.selectedRanges
59 let selectedRanges = textView.selectedRanges
60− textView.string = text
60+ context.coordinator.replaceText(with: text, in: textView)
61 context.coordinator.applyHighlighting(to: textView)
61 context.coordinator.applyHighlighting(to: textView)
62 // NSRange is UTF-16 based; compare against the NSString length, not
62 // NSRange is UTF-16 based; compare against the NSString length, not
63 // String.count (grapheme count), or wide characters drop the selection.
63 // String.count (grapheme count), or wide characters drop the selection.
@@ -101,6 +101,11 @@ struct SQLEditorView: NSViewRepresentable {
101 /// Tracks whether we are inserting a completion so that `textDidChange`
101 /// Tracks whether we are inserting a completion so that `textDidChange`
102 /// doesn't re-trigger the popup for the replacement.
102 /// doesn't re-trigger the popup for the replacement.
103 private var isInsertingCompletion = false
103 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
104
109
105 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
110 init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
106 self.text = text
111 self.text = text
@@ -116,7 +121,8 @@ struct SQLEditorView: NSViewRepresentable {
116 // MARK: - NSTextViewDelegate
121 // MARK: - NSTextViewDelegate
117
122
118 func textDidChange(_ notification: Notification) {
123 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 }
120 isEditing = true
126 isEditing = true
121 clearErrorHighlight(in: textView)
127 clearErrorHighlight(in: textView)
122 text.wrappedValue = textView.string
128 text.wrappedValue = textView.string
@@ -128,6 +134,34 @@ struct SQLEditorView: NSViewRepresentable {
128 }
134 }
129 }
135 }
130
136
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+
131 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
165 func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
132 guard completionPopup.isVisible else { return false }
166 guard completionPopup.isVisible else { return false }
133
167
@@ -223,12 +257,13 @@ struct SQLEditorView: NSViewRepresentable {
223 }
257 }
224 }
258 }
225
259
260+ // Table and column completions carry their own quoting (a column is
226− // A table completion carries its own quotes; absorb any double-quote the
261+ // quoted only when it needs it); absorb any double-quote the user typed
227− // user typed directly around the word so the inserted `"name"` replaces
262+ // directly around the word so the inserted identifier replaces it
228− // it instead of leaving a stray quote. Leading and trailing are handled
263+ // instead of leaving a stray quote. Leading and trailing are handled
229 // independently: a closing quote can exist without an opening one (e.g.
264 // independently: a closing quote can exist without an opening one (e.g.
230 // `FROM my"`), which would otherwise produce a dangling `"name""`.
265 // `FROM my"`), which would otherwise produce a dangling `"name""`.
231− if item.kind == .table {
266+ if item.kind == .table || item.kind == .column {
232 if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
267 if wordStart > 0, nsText.character(at: wordStart - 1) == 0x22 {
233 wordStart -= 1
268 wordStart -= 1
234 }
269 }
ParseableViewer/Views/SyntaxHighlighting.swift+27−57
@@ -49,9 +49,18 @@ enum SQLSyntaxHighlighter {
49 static let sortedFunctions: [String] = functions.sorted()
49 static let sortedFunctions: [String] = functions.sorted()
50
50
51 // Pre-compiled regexes (created once, reused every keystroke)
51 // Pre-compiled regexes (created once, reused every keystroke)
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).
52− private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
60+ private static let literalOrCommentRegex = try! NSRegularExpression(
53− private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
61+ pattern: "('[^']*(?:''[^']*)*')|(\"[^\"]*(?:\"\"[^\"]*)*\")|(--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|\\z))",
54− private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
62+ options: []
63+ )
55 // Matches the same numeric forms as `SQLTokenizer.consumeNumber`: integers,
64 // Matches the same numeric forms as `SQLTokenizer.consumeNumber`: integers,
56 // decimals, leading-dot floats (`.5`), and exponents (`1e5`, `1.5e-3`). The
65 // decimals, leading-dot floats (`.5`), and exponents (`1e5`, `1.5e-3`). The
57 // lookbehind keeps digits that belong to an identifier (`col1`, `t.2`) from
66 // lookbehind keeps digits that belong to an identifier (`col1`, `t.2`) from
@@ -80,20 +89,21 @@ enum SQLSyntaxHighlighter {
80 let nsText = text as NSString
89 let nsText = text as NSString
81 var protectedRanges: [NSRange] = []
90 var protectedRanges: [NSRange] = []
82
91
83− // 1. Single-quoted strings
92+ // 1–3. Single-quoted strings, double-quoted identifiers, and comments, in
84− applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
93+ // one pass so none of them can start inside another (`'a--b'` stays a
85− color: .systemRed, protectedRanges: &protectedRanges)
94+ // string, `-- don't` stays a comment).
86−
95+ let literalMatches = literalOrCommentRegex.matches(
87− // 2. Double-quoted identifiers
96+ in: text, range: NSRange(location: 0, length: nsText.length)
88− applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
97+ )
89− color: .systemRed, protectedRanges: &protectedRanges)
98+ for match in literalMatches {
90−
99+ let isComment = match.range(at: 3).location != NSNotFound
91− // 3. Comments (-- to end of line). Highlighted AFTER strings, and a `--`
100+ textStorage.addAttribute(
92− // that begins inside a string literal (protectedRanges currently holds
101+ .foregroundColor,
93− // only string ranges) is skipped — otherwise `'a--b' AND y = 5` would
102+ value: isComment ? NSColor.secondaryLabelColor : NSColor.systemRed,
94− // bleed a gray comment over the trailing real SQL.
103+ range: match.range
95− applyCommentRegex(commentRegex, to: textStorage, text: nsText,
104+ )
96− color: .secondaryLabelColor, protectedRanges: &protectedRanges)
105+ protectedRanges.append(match.range)
106+ }
97
107
98 // 4. Numbers (outside protected ranges)
108 // 4. Numbers (outside protected ranges)
99 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
109 applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
@@ -110,46 +120,6 @@ enum SQLSyntaxHighlighter {
110 protectedRanges: protectedRanges)
120 protectedRanges: protectedRanges)
111 }
121 }
112
122
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−
153 private static func applyUnprotectedRegex(
123 private static func applyUnprotectedRegex(
154 _ regex: NSRegularExpression,
124 _ regex: NSRegularExpression,
155 to textStorage: NSTextStorage,
125 to textStorage: NSTextStorage,
ParseableViewerTests/QueryViewModelTests.swift+122−2
@@ -235,7 +235,7 @@ final class QueryViewModelTests: XCTestCase {
235 let vm = QueryViewModel()
235 let vm = QueryViewModel()
236 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
236 vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
237 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
237 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'"))
239 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
239 XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
240 }
240 }
241
241
@@ -288,7 +288,7 @@ final class QueryViewModelTests: XCTestCase {
288 let vm = QueryViewModel()
288 let vm = QueryViewModel()
289 vm.sqlQuery = "SELECT * FROM \"logs\""
289 vm.sqlQuery = "SELECT * FROM \"logs\""
290 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
290 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'"))
292 }
292 }
293
293
294 func testAddColumnFilterEscapesSingleQuotesInValue() {
294 func testAddColumnFilterEscapesSingleQuotesInValue() {
@@ -671,5 +671,125 @@ final class QueryViewModelTests: XCTestCase {
671 let empty = vm.emptyColumns(in: records, columns: ["a"])
671 let empty = vm.emptyColumns(in: records, columns: ["a"])
672 XCTAssertTrue(empty.isEmpty)
672 XCTAssertTrue(empty.isEmpty)
673 }
673 }
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+ }
674}
794}
675
795
ParseableViewerTests/SQLAutoCompleteTests.swift+56
@@ -275,6 +275,62 @@ final class SQLAutoCompleteTests: XCTestCase {
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
275 let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276 XCTAssertEqual(ctx, .tableRef)
276 XCTAssertEqual(ctx, .tableRef)
277 }
277 }
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+ }
278}
334}
279
335
280// Make SQLCompletionContext Equatable for assertions
336// Make SQLCompletionContext Equatable for assertions
ParseableViewerTests/SQLTokenizerTests.swift+15
@@ -348,4 +348,19 @@ final class SQLTokenizerTests: XCTestCase {
348 let sql = "SELECT * FROM t"
348 let sql = "SELECT * FROM t"
349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
349 XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
350 }
350 }
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+ }
351}
366}
ParseableViewerTests/SyntaxHighlightingTests.swift+10
@@ -140,6 +140,16 @@ final class SyntaxHighlightingTests: XCTestCase {
140 "Double-quoted identifiers should differ from keywords")
140 "Double-quoted identifiers should differ from keywords")
141 }
141 }
142
142
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+
143 // MARK: - JSON Highlighting
153 // MARK: - JSON Highlighting
144
154
145 func testJSONHighlightProducesOutput() {
155 func testJSONHighlightProducesOutput() {