git.iko.soy/parseable-macos
on master help sign in
Fix 3 bugs: executeQuery stream supersession, stale schema/query state, number regex; align filter test to shipped behavior
commitc043310f2523e2074606d3b9309ad3f6723dfbd0 authoriko <mail@iko.soy> dateWed 24 Jun 2026, 08:27 (3 months ago) parente792c42
3 files changed+35−2
ParseableViewer/ViewModels/QueryViewModel.swift+16 ParseableViewer/Views/SyntaxHighlighting.swift+5−1 ParseableViewerTests/LiveTailViewModelTests.swift+14−1
ParseableViewer/ViewModels/QueryViewModel.swift+16
@@ -80,6 +80,11 @@ final class QueryViewModel {
8080 /// The stream of the most recent `loadSchema` call, used to drop a stale
8181 /// in-flight response when the user rapidly switches streams.
8282 private(set) var latestSchemaStream: String?
83+ /// The stream of the most recent `executeQuery` call. Defense-in-depth
84+ /// alongside `queryGeneration`/task cancellation: drops a stale in-flight
85+ /// query response if a newer query for a different stream supersedes it
86+ /// without going through the normal cancel path.
87+ private var latestQueryStream: String?
8388
8489 // Query task for cancellation
8590 private var queryTask: Task<Void, Never>?
@@ -415,6 +420,7 @@ final class QueryViewModel {
415420 // the loading indicator during this query).
416421 queryGeneration &+= 1
417422 let generation = queryGeneration
423+ latestQueryStream = stream
418424
419425 isLoading = true
420426 errorMessage = nil
@@ -442,6 +448,9 @@ final class QueryViewModel {
442448 // cancellation landing just after the check above would otherwise
443449 // let a stale query clobber the newer one's results/columns.
444450 guard !Task.isCancelled else { return }
451+ // Drop the response if a newer query (possibly for a different
452+ // stream) has since been launched without cancelling this task.
453+ guard latestQueryStream == stream else { return }
445454
446455 results = queryResults
447456 // Drop any prior row selection: the new result set reassigns row
@@ -484,6 +493,9 @@ final class QueryViewModel {
484493 return
485494 } catch {
486495 guard !Task.isCancelled else { return }
496+ // Same supersession guard as the success path: a stale failed
497+ // query must not clobber a newer query's state.
498+ guard latestQueryStream == stream else { return }
487499 errorMessage = ParseableError.userFriendlyMessage(for: error)
488500 if let serverError = error as? ParseableError,
489501 case .serverError(_, let msg) = serverError,
@@ -682,6 +694,10 @@ final class QueryViewModel {
682694 errorRange = nil
683695 resultsTruncated = false
684696 schemaFields = []
697+ // Keep the stream trackers consistent with the cleared schema/results so
698+ // a later reload guard never sees a non-nil stream paired with empty data.
699+ latestSchemaStream = nil
700+ latestQueryStream = nil
685701 }
686702
687703 /// Modifies `sqlQuery` to add a column-value filter condition.
ParseableViewer/Views/SyntaxHighlighting.swift+5−1
@@ -52,7 +52,11 @@ enum SQLSyntaxHighlighter {
5252 private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
5353 private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
5454 private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
55− private static let numberRegex = try! NSRegularExpression(pattern: "\\b\\d+(?:\\.\\d+)?\\b", options: [])
55+ // Matches the same numeric forms as `SQLTokenizer.consumeNumber`: integers,
56+ // decimals, leading-dot floats (`.5`), and exponents (`1e5`, `1.5e-3`). The
57+ // lookbehind keeps digits that belong to an identifier (`col1`, `t.2`) from
58+ // being highlighted as numbers.
59+ private static let numberRegex = try! NSRegularExpression(pattern: "(?<![\\w.])(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?", options: [])
5660 private static let keywordRegex: NSRegularExpression = {
5761 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
5862 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
ParseableViewerTests/LiveTailViewModelTests.swift+14−1
@@ -404,10 +404,23 @@ final class LiveTailViewModelTests: XCTestCase {
404404 XCTAssertEqual(vm.columnFilters.count, 1)
405405 }
406406
407− func testAddFilterDifferentValueIsAdditive() {
407+ func testAddFilterSameColumnIncludeReplaces() {
408408 let vm = LiveTailViewModel()
409409 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
410+ // A second include on the same column replaces the first: includes are
411+ // AND-combined and a column can only equal one value, so stacking two
412+ // includes would match zero rows and silently empty the tail.
410413 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
414+ XCTAssertEqual(vm.columnFilters.count, 1)
415+ XCTAssertEqual(vm.columnFilters[0].value, .string("warn"))
416+ }
417+
418+ func testAddFilterSameColumnExcludesStack() {
419+ let vm = LiveTailViewModel()
420+ vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
421+ // Excludes on the same column legitimately stack: level ≠ debug AND
422+ // level ≠ trace is meaningful, so both filters are retained.
423+ vm.addColumnFilter(column: "level", value: .string("trace"), exclude: true)
411424 XCTAssertEqual(vm.columnFilters.count, 2)
412425 }
413426