@@ -59,29 +59,9 @@ 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] = []
72 var autoHiddenColumns: Set<String> = []
62 var autoHiddenColumns: Set<String> = []
73 /// Column config from a saved query, applied on next query execution.
63 /// Column config from a saved query, applied on next query execution.
74 var pendingColumnConfig: ColumnConfiguration?
64 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?
65 private var currentStream: String?
86 var isLoading = false
66 var isLoading = false
87 var errorMessage: String?
67 var errorMessage: String?
@@ -212,16 +192,9 @@ final class QueryViewModel {
212 return columnOrder.filter { !allHidden.contains($0) }
192 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) {
195 func toggleColumnVisibility(_ column: String) {
222 if hiddenColumns.contains(column) {
196 if hiddenColumns.contains(column) {
223 hiddenColumns.remove(column)
197 hiddenColumns.remove(column)
224− savedHiddenColumns.remove(column)
225 } else if autoHiddenColumns.contains(column) {
198 } else if autoHiddenColumns.contains(column) {
226 autoHiddenColumns.remove(column)
199 autoHiddenColumns.remove(column)
@@ -230,7 +203,6 @@ final class QueryViewModel {
230 let visibleCount = columnOrder.count - allHidden.count
203 let visibleCount = columnOrder.count - allHidden.count
231 if visibleCount > 1 {
204 if visibleCount > 1 {
232 hiddenColumns.insert(column)
205 hiddenColumns.insert(column)
233− savedHiddenColumns.insert(column)
236 saveColumnConfig()
208 saveColumnConfig()
@@ -239,7 +211,6 @@ final class QueryViewModel {
240 func showAllColumns() {
212 func showAllColumns() {
241 hiddenColumns.removeAll()
213 hiddenColumns.removeAll()
242− savedHiddenColumns.removeAll()
243 autoHiddenColumns.removeAll()
214 autoHiddenColumns.removeAll()
244 saveColumnConfig()
215 saveColumnConfig()
245 updateSQLColumns()
216 updateSQLColumns()
@@ -269,116 +240,32 @@ final class QueryViewModel {
270 func resetColumnConfig() {
241 func resetColumnConfig() {
271 columnOrder = columns
242 columnOrder = columns
272− savedColumnOrder = []
273 hiddenColumns.removeAll()
243 hiddenColumns.removeAll()
274− savedHiddenColumns.removeAll()
275 autoHiddenColumns.removeAll()
244 autoHiddenColumns.removeAll()
276 saveColumnConfig()
245 saveColumnConfig()
277 updateSQLColumns()
246 updateSQLColumns()
280− /// Rewrites the SELECT clause of `sqlQuery` to reflect the user's column
249+ /// Rewrites the SELECT clause of `sqlQuery` to reflect the current `visibleColumns`.
281− /// choices (order and hidden columns; auto-hidden columns stay display-only).
282 func updateSQLColumns() {
250 func updateSQLColumns() {
283− guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery),
251+ guard let range = SQLTokenizer.selectColumnListRange(in: sqlQuery) else { return }
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
253+ let visible = visibleColumns
294− // omits all-null fields from query JSON, so a sparse listed field can be
254+ guard !visible.isEmpty else { return }
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
256 let newColumnList: String
308 // Use `*` whenever every column is visible, regardless of display order —
257 // Use `*` whenever every column is visible, regardless of display order —
309 // reordering columns (a set-preserving permutation) must not pin the query
258 // reordering columns (a set-preserving permutation) must not pin the query
310− // to an explicit snapshot that hides newly-appearing fields on re-query —
259+ // 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) {
260+ if visible.count == columns.count && Set(visible) == Set(columns) {
314 newColumnList = "*"
261 newColumnList = "*"
316− newColumnList = (visible + listedButAbsent).map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
263+ newColumnList = visible.map { Self.escapeSQLIdentifier($0) }.joined(separator: ", ")
319 sqlQuery.replaceSubrange(range, with: newColumnList)
266 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
269 // MARK: - Column Configuration Persistence
384 struct ColumnConfiguration: Codable {
271 struct ColumnConfiguration: Codable {
@@ -392,16 +279,7 @@ final class QueryViewModel {
393 private func saveColumnConfig() {
280 private func saveColumnConfig() {
394 guard let stream = currentStream else { return }
281 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))
282+ let config = ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
405 if let data = try? JSONEncoder().encode(config) {
283 if let data = try? JSONEncoder().encode(config) {
406 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
284 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
@@ -430,9 +308,7 @@ final class QueryViewModel {
431 guard let config else {
309 guard let config else {
432 columnOrder = extractedColumns
310 columnOrder = extractedColumns
433− savedColumnOrder = []
434 hiddenColumns = []
311 hiddenColumns = []
435− savedHiddenColumns = []
@@ -445,8 +321,6 @@ final class QueryViewModel {
445 merged.append(col)
321 merged.append(col)
447 columnOrder = merged
323 columnOrder = merged
448− savedColumnOrder = config.order
449− savedHiddenColumns = config.hidden
450 hiddenColumns = config.hidden.intersection(extractedSet)
324 hiddenColumns = config.hidden.intersection(extractedSet)
452 // The `visibleCount > 1` guard in toggleColumnVisibility only covers
326 // The `visibleCount > 1` guard in toggleColumnVisibility only covers
@@ -645,8 +519,6 @@ final class QueryViewModel {
645 // report stale column names after the failure.
519 // report stale column names after the failure.
646 columnOrder = []
520 columnOrder = []
647 hiddenColumns = []
521 hiddenColumns = []
648− savedHiddenColumns = []
649− savedColumnOrder = []
650 autoHiddenColumns = []
522 autoHiddenColumns = []
@@ -813,8 +685,6 @@ final class QueryViewModel {
813 columns = []
685 columns = []
814 columnOrder = []
686 columnOrder = []
815 hiddenColumns = []
687 hiddenColumns = []
816− savedHiddenColumns = []
817− savedColumnOrder = []
818 autoHiddenColumns = []
688 autoHiddenColumns = []
819 currentStream = nil
689 currentStream = nil
820 resultCount = 0
690 resultCount = 0
@@ -828,7 +698,6 @@ final class QueryViewModel {
828 // a later reload guard never sees a non-nil stream paired with empty data.
698 // a later reload guard never sees a non-nil stream paired with empty data.
829 latestSchemaStream = nil
699 latestSchemaStream = nil
830 latestQueryStream = nil
700 latestQueryStream = nil
834 /// Modifies `sqlQuery` to add a column-value filter condition.
703 /// Modifies `sqlQuery` to add a column-value filter condition.
@@ -838,139 +707,75 @@ final class QueryViewModel {
838 let condition: String
707 let condition: String
839 if let value, value != .null {
708 if let value, value != .null {
840 let literal = value.sqlLiteral
709 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)"
847− : "\(escapedCol) = \(literal)"
710+ condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
849 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
712 condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
852− let sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
715+ var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
853 if sql.isEmpty { return }
716 if sql.isEmpty { return }
855 // Locate the top-level WHERE and the first trailing clause using the
718 // Locate the top-level WHERE and the first trailing clause using the
856 // tokenizer rather than a raw regex, so keywords inside quoted
719 // tokenizer rather than a raw regex, so keywords inside quoted
857 // identifiers, string literals, comments, or subqueries are ignored.
720 // identifiers, string literals, comments, or subqueries are ignored.
858− let tokens = SQLTokenizer.tokenize(sql)
721+ let tailKeywords: Set<String> = ["ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET"]
859 var depth = 0
722 var depth = 0
860− // WHERE and the trailing clauses only follow the top-level FROM. Keywords
723+ var whereEnd: String.Index?
861− // before it belong to the SELECT list — e.g. a field named `offset` or
724+ var tailStart: String.Index?
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
725 // 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.
726 // AND-ing a new condition need parentheses to preserve precedence.
869 var bodyHasTopLevelOr = false
727 var bodyHasTopLevelOr = false
870− // The last token that is actual SQL — not whitespace, a comment, or a
728+ for token in SQLTokenizer.tokenize(sql) {
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 }
902 switch token.kind {
729 switch token.kind {
903 case .leftParen:
730 case .leftParen:
904 depth += 1
731 depth += 1
905 case .rightParen:
732 case .rightParen:
906 depth = max(0, depth - 1)
733 depth = max(0, depth - 1)
907− case .keyword("FROM") where depth == 0:
909− case .keyword(let kw) where depth == 0 && fromSeen:
734+ case .keyword(let kw) where depth == 0:
910− if kw == "WHERE", whereIndex == nil {
735+ if kw == "WHERE", whereEnd == nil {
911− whereIndex = idx
736+ whereEnd = token.range.upperBound
912− // A trailing clause seen *before* the WHERE (e.g. the GROUP BY of
737+ } else if tailKeywords.contains(kw), tailStart == nil {
913− // the first SELECT in `... GROUP BY a UNION ALL SELECT ... WHERE`)
738+ tailStart = token.range.lowerBound
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 {
739+ } else if kw == "OR", whereEnd != nil, tailStart == nil {
921 bodyHasTopLevelOr = true
740 bodyHasTopLevelOr = true
927− // Nothing but comments/semicolons — there's no statement to filter.
746+
928− guard let lastSignificant else { return }
747+ if let whereEnd {
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) }
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
748+ // Parenthesize the existing WHERE body before AND-ing the new
948− // condition only when it has a top-level OR, otherwise the OR
749+ // condition only when it has a top-level OR, otherwise the OR
949− // silently changes meaning: `WHERE a = 1 OR b = 2` would become
750+ // 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
751+ // `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
752+ // `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
753+ // `(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.
754+ // left un-parenthesized so simple filters stay readable.
954− let bodyStart = tokens[first].range.lowerBound
755+ let bodyEnd = tailStart ?? sql.endIndex
955− let bodyEnd = tokens[last].range.upperBound
756+ let head = sql[..<whereEnd]
956− let body = sql[bodyStart..<bodyEnd]
757+ let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
957− let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : String(body)
758+ let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
958− sqlQuery = "\(sql[..<bodyStart])\(wrappedBody) AND \(condition)\(separated(sql[bodyEnd...]))"
759+ // An empty WHERE body (e.g. a half-written `... WHERE` with no
760+ // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
761+ // LIMIT) would otherwise produce `WHERE AND <condition>` — a
762+ // dangling AND with no left operand. Drop the AND in that case.
764+ sql = "\(head) \(condition)"
960− // An empty WHERE body (e.g. a half-written `... WHERE` with no
766+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
961− // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
767+ sql = "\(head) \(wrappedBody) AND \(condition)"
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 {
772+ } else if let tailStart {
968− let tailStart = tokens[tailIndex].range.lowerBound
969− sqlQuery = "\(sql[..<tailStart])WHERE \(condition) \(sql[tailStart...])"
773+ sql.insert(contentsOf: "WHERE \(condition) ", at: tailStart)
971− let statementEnd = tokens[lastSignificant].range.upperBound
775+ sql += " WHERE \(condition)"
972− sqlQuery = "\(sql[..<statementEnd]) WHERE \(condition)\(separated(sql[statementEnd...]))"
976 /// Sets the default query for the given stream, returning `true` if the
781 /// Sets the default query for the given stream, returning `true` if the
@@ -1014,10 +819,6 @@ final class QueryViewModel {
1014 // MARK: - Query History
819 // MARK: - Query History
1016 private func addToHistory(_ entry: QueryHistoryEntry) {
821 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
822 // Deduplicate consecutive identical queries
1022 if queryHistory.first?.sql == entry.sql { return }
823 if queryHistory.first?.sql == entry.sql { return }
1023 queryHistory.insert(entry, at: 0)
824 queryHistory.insert(entry, at: 0)