@@ -1,1047 +0,0 @@
1−import Foundation
2−import SwiftUI
3−
4−@Observable
5−@MainActor
6−final class QueryViewModel {
7− var sqlQuery = "" {
8− didSet {
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 }
13− }
14− }
15− var results: [LogRecord] = [] {
16− didSet {
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
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: " ")
38− }
39− }.value
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()
50− }
51− }
52− }
53− }
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
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?
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?
85− private var currentStream: String?
86− var isLoading = false
87− var errorMessage: String?
88− var errorRange: NSRange?
89− var resultCount = 0
90− var queryDuration: TimeInterval?
91− var selectedLogEntry: LogRecord?
92− var filterText = "" {
93− didSet { debouncedUpdateFilter() }
94− }
95− private var filterTask: Task<Void, Never>?
96− var resultsTruncated = false
97−
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?
108−
109− // Query task for cancellation
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
119−
120− // Time range
121− var timeRangeOption: TimeRangeOption = .last1Hour
122− var customStartDate = Calendar.current.date(byAdding: .hour, value: -1, to: Date()) ?? Date()
123− var customEndDate = Date()
124−
125− // Query history
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.
130− var historyIsFull: Bool { queryHistory.count >= Self.maxHistory }
131− private static let maxHistory = 50
132−
133− struct QueryHistoryEntry: Identifiable, Codable, Sendable {
134− let id: UUID
135− let sql: String
136− let executedAt: Date
137− let resultCount: Int
138− let duration: TimeInterval
139−
140− init(sql: String, resultCount: Int, duration: TimeInterval) {
141− self.id = UUID()
142− self.sql = sql
143− self.executedAt = Date()
144− self.resultCount = resultCount
145− self.duration = duration
146− }
147− }
148−
149− enum TimeRangeOption: String, CaseIterable, Identifiable {
150− case last5Min = "Last 5 minutes"
151− case last15Min = "Last 15 minutes"
152− case last30Min = "Last 30 minutes"
153− case last1Hour = "Last 1 hour"
154− case last6Hours = "Last 6 hours"
155− case last24Hours = "Last 24 hours"
156− case last7Days = "Last 7 days"
157− case last30Days = "Last 30 days"
158− case custom = "Custom"
159−
160− var id: String { rawValue }
161−
162− /// Key used in UserDefaults / SettingsView @AppStorage for persistence.
163− var settingsKey: String {
164− switch self {
165− case .last5Min: return "last5Min"
166− case .last15Min: return "last15Min"
167− case .last30Min: return "last30Min"
168− case .last1Hour: return "last1Hour"
169− case .last6Hours: return "last6Hours"
170− case .last24Hours: return "last24Hours"
171− case .last7Days: return "last7Days"
172− case .last30Days: return "last30Days"
173− case .custom: return "custom"
174− }
175− }
176−
177− func dateRange() -> (start: Date, end: Date) {
178− let now = Date()
179− let cal = Calendar.current
180− let start: Date
181− switch self {
182− case .last5Min: start = cal.date(byAdding: .minute, value: -5, to: now) ?? now
183− case .last15Min: start = cal.date(byAdding: .minute, value: -15, to: now) ?? now
184− case .last30Min: start = cal.date(byAdding: .minute, value: -30, to: now) ?? now
185− case .last1Hour: start = cal.date(byAdding: .hour, value: -1, to: now) ?? now
186− case .last6Hours: start = cal.date(byAdding: .hour, value: -6, to: now) ?? now
187− case .last24Hours: start = cal.date(byAdding: .hour, value: -24, to: now) ?? now
188− case .last7Days: start = cal.date(byAdding: .day, value: -7, to: now) ?? now
189− case .last30Days: start = cal.date(byAdding: .day, value: -30, to: now) ?? now
190− case .custom: start = now
191− }
192− return (start, now)
193− }
194− }
195−
196− var startDate: Date {
197− if timeRangeOption == .custom {
198− return customStartDate
199− }
200− return timeRangeOption.dateRange().start
201− }
202−
203− var endDate: Date {
204− if timeRangeOption == .custom {
205− return customEndDate
206− }
207− return timeRangeOption.dateRange().end
208− }
209−
210− var visibleColumns: [String] {
211− let allHidden = hiddenColumns.union(autoHiddenColumns)
212− return columnOrder.filter { !allHidden.contains($0) }
213− }
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−
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)
227− } else {
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)
234− }
235− }
236− saveColumnConfig()
237− updateSQLColumns()
238− }
239−
240− func showAllColumns() {
241− hiddenColumns.removeAll()
242− savedHiddenColumns.removeAll()
243− autoHiddenColumns.removeAll()
244− saveColumnConfig()
245− updateSQLColumns()
246− }
247−
248− func moveColumn(from source: IndexSet, to destination: Int) {
249− columnOrder.move(fromOffsets: source, toOffset: destination)
250− saveColumnConfig()
251− updateSQLColumns()
252− }
253−
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)
266− saveColumnConfig()
267− updateSQLColumns()
268− }
269−
270− func resetColumnConfig() {
271− columnOrder = columns
272− savedColumnOrder = []
273− hiddenColumns.removeAll()
274− savedHiddenColumns.removeAll()
275− autoHiddenColumns.removeAll()
276− saveColumnConfig()
277− updateSQLColumns()
278− }
279−
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 }
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) }
291− guard !visible.isEmpty else { return }
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−
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) {
314− newColumnList = "*"
315− } else {
316− newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
317− }
318−
319− sqlQuery.replaceSubrange(range, with: newColumnList)
320− }
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−
382− // MARK: - Column Configuration Persistence
383−
384− struct ColumnConfiguration: Codable {
385− var order: [String]
386− var hidden: Set<String>
387− }
388−
389− private static func columnConfigKey(for stream: String) -> String {
390− "parseable_column_config_\(stream)"
391− }
392−
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(
399− columnOrder,
400− inserting: savedColumnOrder.filter { !present.contains($0) },
401− reference: savedColumnOrder
402− )
403− savedColumnOrder = order
404− let config = ColumnConfiguration(order: order, hidden: savedHiddenColumns.union(hiddenColumns))
405− if let data = try? JSONEncoder().encode(config) {
406− UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
407− }
408− }
409−
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)
413− }
414−
415− func applyColumnConfig(extractedColumns: [String], stream: String?) {
416− columns = extractedColumns
417− currentStream = stream
418−
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 {
423− config = pending
424− pendingColumnConfig = nil
425− } else if let stream {
426− config = Self.loadColumnConfig(for: stream)
427− } else {
428− config = nil
429− }
430−
431− guard let config else {
432− columnOrder = extractedColumns
433− savedColumnOrder = []
434− hiddenColumns = []
435− savedHiddenColumns = []
436− return
437− }
438−
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) {
445− merged.append(col)
446− }
447− columnOrder = merged
448− savedColumnOrder = config.order
449− savedHiddenColumns = config.hidden
450− hiddenColumns = config.hidden.intersection(extractedSet)
451−
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)
461− }
462− }
463−
464− private(set) var filteredResults: [LogRecord] = []
465−
466− private func debouncedUpdateFilter() {
467− filterTask?.cancel()
468− if filterText.isEmpty {
469− updateFilteredResults()
470− return
471− }
472− filterTask = Task {
473− try? await Task.sleep(for: .milliseconds(150))
474− guard !Task.isCancelled else { return }
475− updateFilteredResults()
476− }
477− }
478−
479− private func updateFilteredResults() {
480− if filterText.isEmpty {
481− filteredResults = results
482− } else {
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
492− }
493− } else {
494− filteredResults = results.filter { record in
495− record.values
496− .map { $0.displayString }
497− .joined(separator: " ")
498− .localizedCaseInsensitiveContains(text)
499− }
500− }
501− }
502− }
503−
504− private var queryLimit: Int {
505− let stored = UserDefaults.standard.integer(forKey: "maxQueryResults")
506− guard stored > 0 else { return 1000 }
507− return min(stored, 100_000)
508− }
509−
510− init() {
511− queryHistory = Self.loadHistory()
512− // Apply the default time range from settings
513− if let stored = UserDefaults.standard.string(forKey: "defaultTimeRange"),
514− let option = TimeRangeOption.allCases.first(where: { $0.settingsKey == stored }) {
515− timeRangeOption = option
516− }
517− }
518−
519− @MainActor
520− func executeQuery(client: ParseableClient?, stream: String?) async {
521− guard let client else {
522− errorMessage = "Not connected to server"
523− return
524− }
525−
526− let limit = queryLimit
527− let sql: String
528− let usedAutoLimit: Bool
529− if sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
530− guard let stream else {
531− errorMessage = "Select a stream or enter a SQL query"
532− return
533− }
534− sql = "SELECT * FROM \(Self.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT \(limit)"
535− usedAutoLimit = true
536− } else {
537− sql = sqlQuery
538− usedAutoLimit = false
539− }
540−
541− // Cancel any in-flight query
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
550−
551− isLoading = true
552− errorMessage = nil
553− errorRange = nil
554− resultsTruncated = false
555− let startTime = CFAbsoluteTimeGetCurrent()
556−
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.
563− defer {
564− if generation == queryGeneration {
565− isLoading = false
566− }
567− }
568− do {
569− try Task.checkCancellation()
570− let queryResults = try await client.query(sql: sql, startTime: startDate, endTime: endDate)
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 }
580−
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
586− queryDuration = CFAbsoluteTimeGetCurrent() - startTime
587− resultCount = results.count
588−
589− // Detect if results were likely truncated. We know the cap for
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)
595− resultsTruncated = effectiveLimit.map { results.count == $0 } ?? false
596−
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)
601−
602− // Auto-hide columns that have no values in any row.
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
612− }
613−
614− // Record in history
615− let entry = QueryHistoryEntry(sql: sql, resultCount: resultCount, duration: queryDuration ?? 0)
616− addToHistory(entry)
617− } catch is CancellationError {
618− // Silently ignore — cancelQuery() or a new executeQuery() handles state
619− return
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 }
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),
629− sqlQuery == sql {
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
639− )
640− }
641− results = []
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.
646− columnOrder = []
647− hiddenColumns = []
648− savedHiddenColumns = []
649− savedColumnOrder = []
650− autoHiddenColumns = []
651− }
652− }
653− queryTask = task
654− await task.value
655− }
656−
657− @MainActor
658− func cancelQuery() {
659− queryTask?.cancel()
660− queryTask = nil
661− isLoading = false
662− errorMessage = "Query cancelled"
663− errorRange = nil
664− }
665−
666− @MainActor
667− func loadSchema(client: ParseableClient?, stream: String?) async {
668− latestSchemaStream = stream
669− guard let client, let stream else {
670− schemaFields = []
671− return
672− }
673− do {
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
680− } catch {
681− guard latestSchemaStream == stream else { return }
682− // Schema load is best-effort; don't surface errors
683− schemaFields = []
684− }
685− }
686−
687− /// Single-pass column extraction with priority ordering.
688− func extractColumns(from records: [LogRecord]) -> [String] {
689− let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
690− var allKeys = Set<String>()
691− for record in records {
692− allKeys.formUnion(record.keys)
693− }
694−
695− var ordered: [String] = []
696− for field in priorityFields {
697− if allKeys.remove(field) != nil {
698− ordered.append(field)
699− }
700− }
701− ordered.append(contentsOf: allKeys.sorted())
702− return ordered
703− }
704−
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)
716− }
717− }
718− }
719− return empty
720− }
721−
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.
725− func exportAsJSON() -> String {
726− let rows = filteredResults
727− guard !rows.isEmpty else { return "[]" }
728− let encoder = JSONEncoder()
729− encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
730− if let data = try? encoder.encode(Self.projectRecords(rows, to: visibleColumns)),
731− let string = String(data: data, encoding: .utf8) {
732− return string
733− }
734− return "[]"
735− }
736−
737− /// Exports the on-screen rows as CSV. Uses `filteredResults` for the same
738− /// reason as `exportAsJSON`.
739− func exportAsCSV() -> String {
740− Self.buildCSV(records: filteredResults, columns: visibleColumns)
741− }
742−
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
745− /// any thread.
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) } }
752− }
753−
754− /// Builds a CSV string from records and columns. Safe to call from any thread.
755− nonisolated static func buildCSV(records: [LogRecord], columns: [String]) -> String {
756− guard !records.isEmpty, !columns.isEmpty else { return "" }
757−
758− var csv = String()
759− csv.reserveCapacity(columns.count * 50 * (records.count + 1))
760−
761− // Header
762− for (i, col) in columns.enumerated() {
763− if i > 0 { csv.append(",") }
764− csv.append(escapeCSV(col))
765− }
766− csv.append("\n")
767−
768− // Rows
769− for record in records {
770− for (i, column) in columns.enumerated() {
771− if i > 0 { csv.append(",") }
772− let value = record[column]?.exportString ?? ""
773− csv.append(escapeCSV(value))
774− }
775− csv.append("\n")
776− }
777−
778− return csv
779− }
780−
781− nonisolated private static func escapeCSV(_ value: String) -> String {
782− // Check at the Unicode scalar level to correctly detect \r and \n
783− // inside \r\n (CRLF) grapheme clusters, which Swift's String.contains
784− // treats as a single Character that doesn't match "\r" or "\n" alone.
785− let needsQuoting = value.unicodeScalars.contains { scalar in
786− scalar == "," || scalar == "\"" || scalar == "\n" || scalar == "\r"
787− }
788− if needsQuoting {
789− return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
790− }
791− return value
792− }
793−
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.
799− queryTask?.cancel()
800− queryTask = nil
801− searchTextTask?.cancel()
802− searchTextTask = nil
803− filterTask?.cancel()
804− filterTask = nil
805−
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.
810− isLoading = false
811−
812− results = []
813− columns = []
814− columnOrder = []
815− hiddenColumns = []
816− savedHiddenColumns = []
817− savedColumnOrder = []
818− autoHiddenColumns = []
819− currentStream = nil
820− resultCount = 0
821− queryDuration = nil
822− selectedLogEntry = nil
823− errorMessage = nil
824− errorRange = nil
825− resultsTruncated = false
826− schemaFields = []
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
831− boundContext = nil
832− }
833−
834− /// Modifies `sqlQuery` to add a column-value filter condition.
835− /// When `exclude` is true, the condition excludes the value; otherwise it includes only matching rows.
836− func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
837− let escapedCol = Self.escapeSQLIdentifier(column)
838− let condition: String
839− if let value, value != .null {
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)"
847− : "\(escapedCol) = \(literal)"
848− } else {
849− condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
850− }
851−
852− let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
853− if sql.isEmpty { return }
854−
855− // Locate the top-level WHERE and the first trailing clause using the
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)
859− var depth = 0
860− // WHERE and the trailing clauses only follow the top-level FROM. Keywords
861− // before it belong to the SELECT list — e.g. a field named `offset` or
862− // `limit` (which the tokenizer reports as keywords) — and must not be
863− // mistaken for clause starts.
864− var fromSeen = false
865− var whereIndex: Int?
866− var tailIndex: Int?
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(";")
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 }
902− switch token.kind {
903− case .leftParen:
904− depth += 1
905− case .rightParen:
906− depth = max(0, depth - 1)
907− case .keyword("FROM") where depth == 0:
908− fromSeen = true
909− case .keyword(let kw) where depth == 0 && fromSeen:
910− if kw == "WHERE", whereIndex == nil {
911− whereIndex = idx
912− // A trailing clause seen *before* the WHERE (e.g. the GROUP BY of
913− // the first SELECT in `... GROUP BY a UNION ALL SELECT ... WHERE`)
914− // doesn't end this WHERE's body. Keeping it would make the body
915− // range run backwards (a trapping `Range`), so look for the
916− // body's end afresh after the WHERE.
917− tailIndex = nil
918− } else if tailIndex == nil, startsTrailingClause(kw, at: idx) {
919− tailIndex = idx
920− } else if kw == "OR", whereIndex != nil, tailIndex == nil {
921− bodyHasTopLevelOr = true
922− }
923− default:
924− break
925− }
926− }
927− // Nothing but comments/semicolons — there's no statement to filter.
928− guard let lastSignificant else { return }
929−
930− // Each branch rebuilds the query from slices of the original text, keeping
931− // the user's line breaks: joining pieces with single spaces would pull the
932− // text after a `-- comment` onto the comment's line and comment it out.
933− // Text spliced directly in front of another token (`(a = 1)ORDER BY`) needs
934− // a space so the two don't fuse (`... IS NULLORDER BY`).
935− func separated(_ rest: Substring) -> String {
936− guard let next = rest.first, !next.isWhitespace, next != ";" else { return String(rest) }
937− return " " + rest
938− }
939− if let whereIndex {
940− // The WHERE body runs up to the first trailing clause, or to the end
941− // of the statement. Only its significant tokens bound the splice, so a
942− // comment between the body and ORDER BY stays after the new condition.
943− let bodyEndIndex = tailIndex ?? (lastSignificant + 1)
944− let bodyIndices = (whereIndex + 1)..<bodyEndIndex
945− if let first = bodyIndices.first(where: { isSignificant(tokens[$0]) }),
946− let last = bodyIndices.last(where: { isSignificant(tokens[$0]) }) {
947− // Parenthesize the existing WHERE body before AND-ing the new
948− // condition only when it has a top-level OR, otherwise the OR
949− // silently changes meaning: `WHERE a = 1 OR b = 2` would become
950− // `WHERE a = 1 OR b = 2 AND c = 3`, which SQL binds as
951− // `a = 1 OR (b = 2 AND c = 3)` rather than the intended
952− // `(a = 1 OR b = 2) AND c = 3`. A body without a top-level OR is
953− // left un-parenthesized so simple filters stay readable.
954− let bodyStart = tokens[first].range.lowerBound
955− let bodyEnd = tokens[last].range.upperBound
956− let body = sql[bodyStart..<bodyEnd]
957− let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : String(body)
958− sqlQuery = "\(sql[..<bodyStart])\(wrappedBody) AND \(condition)\(separated(sql[bodyEnd...]))"
959− } else {
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...]))"
966− }
967− } else if let tailIndex {
968− let tailStart = tokens[tailIndex].range.lowerBound
969− sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
970− } else {
971− let statementEnd = tokens[lastSignificant].range.upperBound
972− sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
973− }
974− }
975−
976− /// Sets the default query for the given stream, returning `true` if the
977− /// query text was replaced (i.e. the user hadn't customized it).
978− @discardableResult
979− func setDefaultQuery(stream: String, previousStream: String? = nil) -> Bool {
980− // Always replace when switching streams — the previous query (whether
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).
983− let shouldReplace: Bool
984− if sqlQuery.isEmpty || previousStream != nil {
985− shouldReplace = true
986− } else {
987− shouldReplace = false
988− }
989−
990− if shouldReplace {
991− sqlQuery = "SELECT * FROM \(Self.escapeSQLIdentifier(stream)) ORDER BY p_timestamp DESC LIMIT \(queryLimit)"
992− }
993− return shouldReplace
994− }
995−
996− /// Escapes a SQL identifier by doubling internal double-quotes, then wrapping in double-quotes.
997− nonisolated static func escapeSQLIdentifier(_ identifier: String) -> String {
998− "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
999− }
1000−
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])
1012− }
1013−
1014− // MARK: - Query History
1015−
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()
1021− // Deduplicate consecutive identical queries
1022− if queryHistory.first?.sql == entry.sql { return }
1023− queryHistory.insert(entry, at: 0)
1024− if queryHistory.count > Self.maxHistory {
1025− queryHistory = Array(queryHistory.prefix(Self.maxHistory))
1026− }
1027− Self.saveHistory(queryHistory)
1028− }
1029−
1030− func clearHistory() {
1031− queryHistory = []
1032− Self.saveHistory([])
1033− }
1034−
1035− private static let historyKey = "parseable_query_history"
1036−
1037− private static func loadHistory() -> [QueryHistoryEntry] {
1038− guard let data = UserDefaults.standard.data(forKey: historyKey) else { return [] }
1039− return (try? JSONDecoder().decode([QueryHistoryEntry].self, from: data)) ?? []
1040− }
1041−
1042− private static func saveHistory(_ history: [QueryHistoryEntry]) {
1043− if let data = try? JSONEncoder().encode(history) {
1044− UserDefaults.standard.set(data, forKey: historyKey)
1045− }
1046− }
1047−}