@@ -2,24 +2,120 @@ import Foundation
2import SwiftUI
2import SwiftUI
5final class QueryViewModel {
6final class QueryViewModel {
6− var sqlQuery = ""
7+ var sqlQuery = "" {
9+ // Editing the query invalidates any error underline, whose offsets
10+ // were computed against the previous text. Clearing here keeps a
11+ // late-arriving highlight from landing on the wrong position.
12+ if sqlQuery != oldValue { errorRange = nil }
7 var results: [LogRecord] = [] {
15 var results: [LogRecord] = [] {
17+ // Build search texts asynchronously
18+ searchTextTask?.cancel()
19+ cachedSearchTexts = []
20+ searchTextGeneration &+= 1
21+ let generation = searchTextGeneration
22+ // Apply the current filter immediately using the inline fallback path
8− didSet { updateFilteredResults() }
23+ // (the cache was just invalidated). Calling updateFilteredResults()
24+ // rather than `filteredResults = results` avoids briefly showing
25+ // unfiltered rows while a filter is active, and avoids leaving a stale
26+ // unfiltered set if the async rebuild below is cancelled before it
27+ // re-applies the filter.
28+ updateFilteredResults()
29+ let snapshot = results
30+ // The class is @MainActor, so this Task inherits main-actor isolation —
31+ // its mutations of cachedSearchTexts/filteredResults are safely serialized
32+ // with SwiftUI reads. Only the pure (and potentially large) displayString
33+ // mapping is pushed off-main via Task.detached so it doesn't block the UI.
34+ searchTextTask = Task { [weak self] in
35+ let texts = await Task.detached(priority: .userInitiated) {
36+ snapshot.map { record in
37+ record.values.map { $0.displayString }.joined(separator: " ")
40+ guard let self, !Task.isCancelled else { return }
41+ // Reject a stale write: if `results` was reassigned while this task
42+ // was awaiting, a newer didSet bumped the generation. Task.isCancelled
43+ // alone isn't sufficient because two re-queries producing result sets
44+ // of identical count would otherwise let this stale, same-length
45+ // cachedSearchTexts slip past updateFilteredResults's count guard.
46+ guard generation == self.searchTextGeneration else { return }
47+ self.cachedSearchTexts = texts
48+ if !self.filterText.isEmpty {
49+ self.updateFilteredResults()
54+ private var cachedSearchTexts: [String] = []
55+ private var searchTextTask: Task<Void, Never>?
56+ /// Bumped on every `results` reassignment so the async search-text builder can
57+ /// detect it was superseded and drop a stale write (see results.didSet).
58+ private var searchTextGeneration = 0
10 var columns: [String] = []
59 var columns: [String] = []
60+ var columnOrder: [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] = []
72+ var autoHiddenColumns: Set<String> = []
73+ /// Column config from a saved query, applied on next query execution.
74+ var pendingColumnConfig: ColumnConfiguration?
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
80+ struct BoundContext: Equatable {
81+ let connectionID: UUID?
84+ var boundContext: BoundContext?
85+ private var currentStream: String?
11 var isLoading = false
86 var isLoading = false
12 var errorMessage: String?
87 var errorMessage: String?
88+ var errorRange: NSRange?
13 var resultCount = 0
89 var resultCount = 0
14 var queryDuration: TimeInterval?
90 var queryDuration: TimeInterval?
15 var selectedLogEntry: LogRecord?
91 var selectedLogEntry: LogRecord?
16 var filterText = "" {
92 var filterText = "" {
17− didSet { updateFilteredResults() }
93+ didSet { debouncedUpdateFilter() }
95+ private var filterTask: Task<Void, Never>?
19 var resultsTruncated = false
96 var resultsTruncated = false
98+ // Schema fields for autocomplete
99+ var schemaFields: [SchemaField] = []
100+ /// The stream of the most recent `loadSchema` call, used to drop a stale
101+ /// in-flight response when the user rapidly switches streams.
102+ private(set) var latestSchemaStream: String?
103+ /// The stream of the most recent `executeQuery` call. Defense-in-depth
104+ /// alongside `queryGeneration`/task cancellation: drops a stale in-flight
105+ /// query response if a newer query for a different stream supersedes it
106+ /// without going through the normal cancel path.
107+ private var latestQueryStream: String?
21 // Query task for cancellation
109 // Query task for cancellation
22 private var queryTask: Task<Void, Never>?
110 private var queryTask: Task<Void, Never>?
111+ /// Bumped at the start of every `executeQuery`. The task captures this value
112+ /// and only clears `isLoading` on exit if it's still the active query — so a
113+ /// superseded query's late cleanup doesn't drop the loading indicator that
114+ /// the newer query set. Without this, `isLoading = false` had to live in
115+ /// every caller of `cancel()` (cancelQuery, clearResults, superseding
116+ /// executeQuery) and a future cancel path that forgot to set it would wedge
117+ /// the spinner indefinitely.
118+ private var queryGeneration = 0
24 // Time range
120 // Time range
25 var timeRangeOption: TimeRangeOption = .last1Hour
121 var timeRangeOption: TimeRangeOption = .last1Hour
@@ -28,7 +124,10 @@ final class QueryViewModel {
29 // Query history
125 // Query history
30 var queryHistory: [QueryHistoryEntry] = []
126 var queryHistory: [QueryHistoryEntry] = []
127+ // Derived from the (always-trimmed) history so it stays correct after a
128+ // restored full history is loaded at launch, not just after an in-session
129+ // overflow. `addToHistory` trims to `maxHistory`, so count never exceeds it.
31− var historyIsFull = false
130+ var historyIsFull: Bool { queryHistory.count >= Self.maxHistory }
32 private static let maxHistory = 50
131 private static let maxHistory = 50
34 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
133 struct QueryHistoryEntry: Identifiable, Codable, Sendable {
@@ -108,15 +207,295 @@ final class QueryViewModel {
108 return timeRangeOption.dateRange().end
207 return timeRangeOption.dateRange().end
210+ var visibleColumns: [String] {
211+ let allHidden = hiddenColumns.union(autoHiddenColumns)
212+ return columnOrder.filter { !allHidden.contains($0) }
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
221+ func toggleColumnVisibility(_ column: String) {
222+ if hiddenColumns.contains(column) {
223+ hiddenColumns.remove(column)
224+ savedHiddenColumns.remove(column)
225+ } else if autoHiddenColumns.contains(column) {
226+ autoHiddenColumns.remove(column)
228+ // Don't allow hiding all columns
229+ let allHidden = hiddenColumns.union(autoHiddenColumns)
230+ let visibleCount = columnOrder.count - allHidden.count
231+ if visibleCount > 1 {
232+ hiddenColumns.insert(column)
233+ savedHiddenColumns.insert(column)
240+ func showAllColumns() {
241+ hiddenColumns.removeAll()
242+ savedHiddenColumns.removeAll()
243+ autoHiddenColumns.removeAll()
248+ func moveColumn(from source: IndexSet, to destination: Int) {
249+ columnOrder.move(fromOffsets: source, toOffset: destination)
254+ func moveColumn(_ column: String, to targetColumn: String) {
255+ guard let fromIndex = columnOrder.firstIndex(of: column),
256+ let toIndex = columnOrder.firstIndex(of: targetColumn),
257+ fromIndex != toIndex else { return }
258+ let item = columnOrder.remove(at: fromIndex)
259+ // Match the drop-indicator semantics: a rightward move (fromIndex < toIndex)
260+ // lands the column *after* the target, a leftward move lands it *before*.
261+ // After removing an earlier element the target shifts down by one, so
262+ // inserting at `toIndex` lands after the target for rightward moves; for
263+ // leftward moves the target is unshifted and `toIndex` lands before it.
264+ let adjusted = toIndex
265+ columnOrder.insert(item, at: adjusted)
270+ func resetColumnConfig() {
271+ columnOrder = columns
272+ savedColumnOrder = []
273+ hiddenColumns.removeAll()
274+ savedHiddenColumns.removeAll()
275+ autoHiddenColumns.removeAll()
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).
282+ func updateSQLColumns() {
283+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
284+ let currentList = Self.parseColumnList(sqlQuery[range]) else { return }
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) }
291+ guard !visible.isEmpty else { return }
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)
307+ let newColumnList: String
308+ // Use `*` whenever every column is visible, regardless of display order —
309+ // reordering columns (a set-preserving permutation) must not pin the 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.
313+ if !excludesHiddenColumn && visible.count == columns.count && Set(visible) == Set(columns) {
316+ newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
319+ sqlQuery.replaceSubrange(range, with: newColumnList)
322+ enum ColumnListShape: Equatable {
324+ /// Plain column references, as the column names they resolve to.
325+ case identifiers([String])
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() {
341+ guard token.kind == .comma else { return nil }
345+ // DataFusion folds unquoted identifiers to lowercase.
346+ case .identifier(let name): names.append(name.lowercased())
347+ case .quotedIdentifier(let name): names.append(name)
351+ return .identifiers(names)
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]
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)
373+ result.insert(column, at: 0)
376+ for column in pending where !result.contains(column) {
377+ result.append(column)
382+ // MARK: - Column Configuration Persistence
384+ struct ColumnConfiguration: Codable {
386+ var hidden: Set<String>
389+ private static func columnConfigKey(for stream: String) -> String {
390+ "parseable_column_config_\(stream)"
393+ private func saveColumnConfig() {
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(
400+ inserting: savedColumnOrder.filter { !present.contains($0) },
401+ reference: savedColumnOrder
403+ savedColumnOrder = order
404+ let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
405+ if let data = try? JSONEncoder().encode(config) {
406+ UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
410+ static func loadColumnConfig(for stream: String) -> ColumnConfiguration? {
411+ guard let data = UserDefaults.standard.data(forKey: columnConfigKey(for: stream)) else { return nil }
412+ return try? JSONDecoder().decode(ColumnConfiguration.self, from: data)
415+ func applyColumnConfig(extractedColumns: [String], stream: String?) {
416+ columns = extractedColumns
417+ currentStream = stream
419+ // Use pending config (from a saved query) if available,
420+ // otherwise fall back to the per-stream default.
421+ let config: ColumnConfiguration?
422+ if let pending = pendingColumnConfig {
424+ pendingColumnConfig = nil
425+ } else if let stream {
426+ config = Self.loadColumnConfig(for: stream)
431+ guard let config else {
432+ columnOrder = extractedColumns
433+ savedColumnOrder = []
435+ savedHiddenColumns = []
439+ // Merge saved order with actual columns: keep saved order for columns
440+ // that still exist, then append any new columns at the end
441+ let extractedSet = Set(extractedColumns)
442+ var merged = config.order.filter { extractedSet.contains($0) }
443+ let mergedSet = Set(merged)
444+ for col in extractedColumns where !mergedSet.contains(col) {
447+ columnOrder = merged
448+ savedColumnOrder = config.order
449+ savedHiddenColumns = config.hidden
450+ hiddenColumns = config.hidden.intersection(extractedSet)
452+ // The `visibleCount > 1` guard in toggleColumnVisibility only covers
453+ // user toggles. A saved config can hide every currently-extracted column
454+ // (e.g. the stream's schema shrank so only previously-hidden columns
455+ // remain), which would render the results table with zero columns and
456+ // leave `updateSQLColumns` early-returning on the empty case. Keep at
457+ // least the first column visible. Mirrors LiveTailViewModel.updateColumns.
458+ if !columnOrder.isEmpty, hiddenColumns.count >= columnOrder.count,
459+ let firstColumn = columnOrder.first {
460+ hiddenColumns.remove(firstColumn)
111 private(set) var filteredResults: [LogRecord] = []
464 private(set) var filteredResults: [LogRecord] = []
466+ private func debouncedUpdateFilter() {
467+ filterTask?.cancel()
468+ if filterText.isEmpty {
469+ updateFilteredResults()
473+ try? await Task.sleep(for: .milliseconds(150))
474+ guard !Task.isCancelled else { return }
475+ updateFilteredResults()
113 private func updateFilteredResults() {
479 private func updateFilteredResults() {
114 if filterText.isEmpty {
480 if filterText.isEmpty {
115 filteredResults = results
481 filteredResults = results
483+ let text = filterText
484+ // The search-text cache is built asynchronously, so it may be empty
485+ // or stale when the filter changes. `zip` would silently truncate to
486+ // the shorter sequence and drop matching rows, so only use the cache
487+ // when it lines up with the current results; otherwise compute the
488+ // search text inline for correctness.
489+ if cachedSearchTexts.count == results.count {
490+ filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
491+ searchText.localizedCaseInsensitiveContains(text) ? record : nil
117− filteredResults = results.filter { record in
494+ filteredResults = results.filter { record in
118− record.values.contains { value in
495+ record.values
496+ .map { $0.displayString }
497+ .joined(separator: " ")
119− value.displayString.localizedCaseInsensitiveContains(filterText)
498+ .localizedCaseInsensitiveContains(text)
@@ -161,43 +540,115 @@ final class QueryViewModel {
162 // Cancel any in-flight query
541 // Cancel any in-flight query
163 queryTask?.cancel()
542 queryTask?.cancel()
543+ // Bump the generation so the in-flight task's cleanup can detect that
544+ // it has been superseded and skip its `isLoading = false` (which would
545+ // otherwise race with the `isLoading = true` below and briefly drop
546+ // the loading indicator during this query).
547+ queryGeneration &+= 1
548+ let generation = queryGeneration
549+ latestQueryStream = stream
165 isLoading = true
551 isLoading = true
166 errorMessage = nil
552 errorMessage = nil
167 resultsTruncated = false
554 resultsTruncated = false
168 let startTime = CFAbsoluteTimeGetCurrent()
555 let startTime = CFAbsoluteTimeGetCurrent()
170 let task = Task {
557 let task = Task {
558+ // Always clear the spinner on exit when this is still the active
559+ // query. The defer runs on every path (success, error, and the
560+ // cancellation cases that previously returned before reaching the
561+ // trailing `isLoading = false`), so the spinner can never wedge
562+ // even if a new cancel path is added without resetting it.
564+ if generation == queryGeneration {
172 try Task.checkCancellation()
569 try Task.checkCancellation()
173 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
570 let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
174 try Task.checkCancellation()
571 try Task.checkCancellation()
572+ // Re-check before committing. A superseding executeQuery() cancels
573+ // this task (queryTask?.cancel()) before starting the next one, but
574+ // cancellation landing just after the check above would otherwise
575+ // let a stale query clobber the newer one's results/columns.
576+ guard !Task.isCancelled else { return }
577+ // Drop the response if a newer query (possibly for a different
578+ // stream) has since been launched without cancelling this task.
579+ guard latestQueryStream == stream else { return }
176 results = queryResults
581 results = queryResults
582+ // Drop any prior row selection: the new result set reassigns row
583+ // identities, so a retained selectedLogEntry would either dangle or
584+ // value-match an unrelated row, leaving a stale detail inspector.
585+ selectedLogEntry = nil
177 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
586 queryDuration = CFAbsoluteTimeGetCurrent() - startTime
178 resultCount = results.count
587 resultCount = results.count
180− // Detect if results were likely truncated (only meaningful
589+ // Detect if results were likely truncated. We know the cap for
181− // for auto-generated queries where we control the LIMIT)
590+ // auto-generated queries; for user-entered SQL (which includes the
591+ // default query `setDefaultQuery` writes into the editor — the common
592+ // stream-selection path), parse a trailing `LIMIT n` so the indicator
593+ // still fires when the row count lands exactly on the limit.
594+ let effectiveLimit = usedAutoLimit ? limit : Self.trailingLimit(in: sql)
182− resultsTruncated = usedAutoLimit && results.count == limit
595+ resultsTruncated = effectiveLimit.map { results.count == $0 } ?? false
597+ // Extract columns in a single pass, with priority fields first,
598+ // then apply any saved column configuration for this stream
599+ let extracted = extractColumns(from: results)
600+ applyColumnConfig(extractedColumns: extracted, stream: stream)
184− // Extract columns in a single pass, with priority fields first
602+ // Auto-hide columns that have no values in any row.
185− columns = extractColumns(from: results)
603+ // Clear first so stale state from a previous query doesn't
604+ // interfere with the visible-count check.
605+ autoHiddenColumns = []
606+ let empty = emptyColumns(in: results, columns: columnOrder)
607+ let candidates = empty.subtracting(hiddenColumns)
608+ let visibleCount = columnOrder.count - hiddenColumns.count
609+ // Ensure at least one column remains visible
610+ if candidates.count < visibleCount {
611+ autoHiddenColumns = candidates
187 // Record in history
614 // Record in history
188 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
615 let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
189 addToHistory(entry)
616 addToHistory(entry)
190 } catch is CancellationError {
617 } catch is CancellationError {
191− errorMessage = "Query cancelled"
618+ // Silently ignore — cancelQuery() or a new executeQuery() handles state
192− results = []
619+ return
194 } catch {
620 } catch {
621+ guard !Task.isCancelled else { return }
622+ // Same supersession guard as the success path: a stale failed
623+ // query must not clobber a newer query's state.
624+ guard latestQueryStream == stream else { return }
195 errorMessage = ParseableError.userFriendlyMessage(for: error)
625 errorMessage = ParseableError.userFriendlyMessage(for: error)
626+ if let serverError = error as? ParseableError,
627+ case .serverError(_, let msg) = serverError,
628+ let pos = SQLErrorPosition.parse(from: msg),
630+ // The server's line/column is relative to the *submitted* SQL.
631+ // Only map it back into the editor when the editor still holds
632+ // that same text — if the user edited the query while it was in
633+ // flight, the position no longer corresponds to what's displayed
634+ // and would underline an unrelated span. (`sqlQuery`'s didSet
635+ // already cleared errorRange on that edit, so leaving it nil here
636+ // keeps the editor unmarked.)
637+ errorRange = SQLTokenizer.errorHighlightRange(
638+ line: pos.line, column: pos.column, in: sql
196 results = []
641 results = []
197 columns = []
642 columns = []
643+ // Drop the previous query's column layout too, otherwise derived
644+ // state like `visibleColumns` (and a CSV export reading it) would
645+ // report stale column names after the failure.
648+ savedHiddenColumns = []
649+ savedColumnOrder = []
650+ autoHiddenColumns = []
202 queryTask = task
653 queryTask = task
203 await task.value
654 await task.value
@@ -209,6 +660,28 @@ final class QueryViewModel {
209 queryTask = nil
660 queryTask = nil
210 isLoading = false
661 isLoading = false
211 errorMessage = "Query cancelled"
662 errorMessage = "Query cancelled"
667+ func loadSchema(client: ParseableClient?, stream: String?) async {
668+ latestSchemaStream = stream
669+ guard let client, let stream else {
674+ let schema = try await client.getStreamSchema(stream: stream)
675+ // A newer load (e.g. a rapid stream switch) may have superseded this
676+ // one; only commit if this is still the stream last requested,
677+ // otherwise we'd show the wrong stream's fields in autocomplete.
678+ guard latestSchemaStream == stream else { return }
679+ schemaFields = schema.fields
681+ guard latestSchemaStream == stream else { return }
682+ // Schema load is best-effort; don't surface errors
214 /// Single-pass column extraction with priority ordering.
687 /// Single-pass column extraction with priority ordering.
@@ -229,39 +702,83 @@ final class QueryViewModel {
229 return ordered
702 return ordered
705+ /// Returns the set of columns that have no meaningful value in any record.
706+ /// A value is considered empty if it is missing (nil), `.null`, or `.string("")`.
707+ func emptyColumns(in records: [LogRecord], columns: [String]) -> Set<String> {
708+ var empty = Set(columns)
709+ for record in records {
710+ if empty.isEmpty { break }
711+ // Iterate a snapshot: mutating `empty` while iterating it directly
712+ // is undefined behavior and can skip elements or trap.
713+ for column in Array(empty) {
714+ if let value = record[column], value != .null, value != .string("") {
715+ empty.remove(column)
722+ /// Exports the on-screen rows as JSON. Uses `filteredResults` (not the full
723+ /// `results`) so the output matches what the user sees with an active result
724+ /// filter, mirroring the view's export path.
232 func exportAsJSON() -> String {
725 func exportAsJSON() -> String {
726+ let rows = filteredResults
233− guard !results.isEmpty else { return "[]" }
727+ guard !rows.isEmpty else { return "[]" }
234 let encoder = JSONEncoder()
728 let encoder = JSONEncoder()
235 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
729 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
236− if let data = try? encoder.encode(results),
730+ if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
237 let string = String(data: data, encoding: .utf8) {
731 let string = String(data: data, encoding: .utf8) {
238 return string
732 return string
240 return "[]"
734 return "[]"
737+ /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
738+ /// reason as `exportAsJSON`.
243 func exportAsCSV() -> String {
739 func exportAsCSV() -> String {
244− Self.buildCSV(records: results, columns: columns)
740+ Self.buildCSV(records: filteredResults, columns: visibleColumns)
743+ /// Projects each record down to the given columns so JSON export, like CSV,
744+ /// respects hidden columns instead of leaking every field. Safe to call from
746+ nonisolated static func projectRecords(_ records: [LogRecord], to columns: [String]) -> [LogRecord] {
747+ // No column list means no restriction (columns not computed yet) — export
748+ // the full records rather than stripping every field to an empty object.
749+ guard !columns.isEmpty else { return records }
750+ let allowed = Set(columns)
751+ return records.map { record in record.filter { allowed.contains($0.key) } }
247 /// Builds a CSV string from records and columns. Safe to call from any thread.
754 /// Builds a CSV string from records and columns. Safe to call from any thread.
248− static func buildCSV(records: [LogRecord], columns: [String]) -> String {
755+ nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
249 guard !records.isEmpty, !columns.isEmpty else { return "" }
756 guard !records.isEmpty, !columns.isEmpty else { return "" }
251− var csv = columns.map { escapeCSV($0) }.joined(separator: ",") + "\n"
758+ var csv = String()
759+ csv.reserveCapacity(columns.count * 50 * (records.count + 1))
762+ for (i, col) in columns.enumerated() {
763+ if i > 0 { csv.append(",") }
764+ csv.append(escapeCSV(col))
253 for record in records {
769 for record in records {
254− let row = columns.map { column in
770+ for (i, column) in columns.enumerated() {
771+ if i > 0 { csv.append(",") }
255 let value = record[column]?.exportString ?? ""
772 let value = record[column]?.exportString ?? ""
256− return escapeCSV(value)
773+ csv.append(escapeCSV(value))
258− csv += row.joined(separator: ",") + "\n"
775+ csv.append("\n")
261 return csv
778 return csv
264− private static func escapeCSV(_ value: String) -> String {
781+ nonisolated private static func escapeCSV(_ value: String) -> String {
265 // Check at the Unicode scalar level to correctly detect \r and \n
782 // Check at the Unicode scalar level to correctly detect \r and \n
266 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
783 // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
267 // treats as a single Character that doesn't match "\r" or "\n" alone.
784 // treats as a single Character that doesn't match "\r" or "\n" alone.
@@ -275,13 +792,43 @@ final class QueryViewModel {
277 func clearResults() {
794 func clearResults() {
795+ // Cancel any in-flight work first. Without this, a query still running
796+ // when the user switches streams or disconnects would pass its post-await
797+ // cancellation check (it was never cancelled) and write its results +
798+ // persist a column config for the *old* stream after this reset.
801+ searchTextTask?.cancel()
802+ searchTextTask = nil
803+ filterTask?.cancel()
806+ // The in-flight query was cancelled above, so its task returns via the
807+ // CancellationError path and never reaches `isLoading = false`. Reset it
808+ // here, otherwise deselecting the stream / disconnecting (which calls
809+ // clearResults with no follow-up query) leaves the spinner stuck on.
278 results = []
812 results = []
279 columns = []
813 columns = []
816+ savedHiddenColumns = []
817+ savedColumnOrder = []
818+ autoHiddenColumns = []
280 resultCount = 0
820 resultCount = 0
281 queryDuration = nil
821 queryDuration = nil
282 selectedLogEntry = nil
822 selectedLogEntry = nil
283 errorMessage = nil
823 errorMessage = nil
284 resultsTruncated = false
825 resultsTruncated = false
827+ // Keep the stream trackers consistent with the cleared schema/results so
828+ // a later reload guard never sees a non-nil stream paired with empty data.
829+ latestSchemaStream = nil
830+ latestQueryStream = nil
287 /// Modifies `sqlQuery` to add a column-value filter condition.
834 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -291,48 +838,151 @@ final class QueryViewModel {
291 let condition: String
838 let condition: String
292 if let value, value != .null {
839 if let value, value != .null {
293 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).
846+ ? "\(escapedCol) IS DISTINCT FROM \(literal)"
294− condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
847+ : "\(escapedCol) = \(literal)"
296 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
849 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
299− var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
852+ let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
300 if sql.isEmpty { return }
853 if sql.isEmpty { return }
302− let wherePattern = #"(?i)\bWHERE\b"#
855+ // Locate the top-level WHERE and the first trailing clause using the
303− let tailPattern = #"(?i)\b(ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT)\b"#
856+ // tokenizer rather than a raw regex, so keywords inside quoted
857+ // identifiers, string literals, comments, or subqueries are ignored.
858+ let tokens = SQLTokenizer.tokenize(sql)
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?
867+ // Track whether the existing WHERE body has a top-level OR — only then does
868+ // AND-ing a new condition need parentheses to preserve precedence.
869+ var bodyHasTopLevelOr = false
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(";")
879+ func nextSignificantKind(after index: Int) -> SQLTokenizer.TokenKind? {
880+ tokens[(index + 1)...].first(where: { isSignificant($0) })?.kind
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 {
888+ case "ORDER", "GROUP":
889+ return nextSignificantKind(after: index) == .keyword("BY")
890+ case "LIMIT", "OFFSET":
891+ if case .number = nextSignificantKind(after: index) { return true }
899+ var lastSignificant: Int?
900+ for (idx, token) in tokens.enumerated() {
901+ if isSignificant(token) { lastSignificant = idx }
906+ depth = max(0, depth - 1)
907+ case .keyword("FROM") where depth == 0:
909+ case .keyword(let kw) where depth == 0 && fromSeen:
910+ if kw == "WHERE", whereIndex == nil {
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.
918+ } else if tailIndex == nil, startsTrailingClause(kw, at: idx) {
920+ } else if kw == "OR", whereIndex != nil, tailIndex == nil {
921+ bodyHasTopLevelOr = true
927+ // Nothing but comments/semicolons — there's no statement to filter.
928+ guard let lastSignificant else { return }
305− if let whereRange = sql.range(of: wherePattern, options: .regularExpression) {
930+ // Each branch rebuilds the query from slices of the original text, keeping
306− let afterWhere = whereRange.upperBound
931+ // the user's line breaks: joining pieces with single spaces would pull the
307− let searchRange = afterWhere..<sql.endIndex
932+ // text after a `-- comment` onto the comment's line and comment it out.
308− if let tailRange = sql.range(of: tailPattern, options: .regularExpression, range: searchRange) {
933+ // Text spliced directly in front of another token (`(a = 1)ORDER BY`) needs
309− sql.insert(contentsOf: "AND \(condition) ", at: tailRange.lowerBound)
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) }
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...]))"
311− sql += " 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...]))"
967+ } else if let tailIndex {
968+ let tailStart = tokens[tailIndex].range.lowerBound
969+ sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
314− if let tailRange = sql.range(of: tailPattern, options: .regularExpression) {
971+ let statementEnd = tokens[lastSignificant].range.upperBound
315− sql.insert(contentsOf: "WHERE \(condition) ", at: tailRange.lowerBound)
972+ sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
317− sql += " WHERE \(condition)"
324 /// 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
325 /// query text was replaced (i.e. the user hadn't customized it).
977 /// query text was replaced (i.e. the user hadn't customized it).
326 @discardableResult
978 @discardableResult
327 func setDefaultQuery(stream: String, previousStream: String? = nil) -> Bool {
979 func setDefaultQuery(stream: String, previousStream: String? = nil) -> Bool {
328− // Replace the query if it's empty or still matches the auto-generated
980+ // Always replace when switching streams — the previous query (whether
329− // default for the previous stream. If the user edited the SQL, keep it.
981+ // auto-generated or from a saved filter) belongs to the old stream.
982+ // Only preserve the query when no stream change occurred (e.g. onAppear).
330 let shouldReplace: Bool
983 let shouldReplace: Bool
331− if sqlQuery.isEmpty {
984+ if sqlQuery.isEmpty || previousStream != nil {
332 shouldReplace = true
985 shouldReplace = true
333− } else if let prev = previousStream {
334− let prevPrefix = "SELECT * FROM \(Self.escapeSQLIdentifier(prev))"
335− shouldReplace = sqlQuery.hasPrefix(prevPrefix)
337 shouldReplace = false
987 shouldReplace = false
@@ -344,19 +994,35 @@ final class QueryViewModel {
346 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
996 /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
347− static func escapeSQLIdentifier(_ identifier: String) -> String {
997+ nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
348 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
998 "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
1001+ /// Extracts the row cap from a trailing `LIMIT n` clause (tolerating a trailing
1002+ /// semicolon/whitespace), used to detect when a result set was truncated.
1003+ /// Returns nil when there's no recognizable trailing `LIMIT` — e.g. when the
1004+ /// query uses `LIMIT n OFFSET m` — so we never report a false truncation.
1005+ nonisolated static func trailingLimit(in sql: String) -> Int? {
1006+ let pattern = #"(?i)\bLIMIT\s+(\d+)\s*;?\s*$"#
1007+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
1008+ let range = NSRange(sql.startIndex..<sql.endIndex, in: sql)
1009+ guard let match = regex.firstMatch(in: sql, range: range),
1010+ let numberRange = Range(match.range(at: 1), in: sql) else { return nil }
1011+ return Int(sql[numberRange])
351 // MARK: - Query History
1014 // MARK: - Query History
353 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()
354 // Deduplicate consecutive identical queries
1021 // Deduplicate consecutive identical queries
355 if queryHistory.first?.sql == entry.sql { return }
1022 if queryHistory.first?.sql == entry.sql { return }
356 queryHistory.insert(entry, at: 0)
1023 queryHistory.insert(entry, at: 0)
357 if queryHistory.count > Self.maxHistory {
1024 if queryHistory.count > Self.maxHistory {
358 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
1025 queryHistory = Array(queryHistory.prefix(Self.maxHistory))
359− historyIsFull = true
361 Self.saveHistory(queryHistory)
1027 Self.saveHistory(queryHistory)