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
c043310iko
ParseableViewer/ViewModels/QueryViewModel.swift+16
@@ -80,6 +80,11 @@ final class QueryViewModel {
80 /// The stream of the most recent `loadSchema` call, used to drop a stale
80 /// The stream of the most recent `loadSchema` call, used to drop a stale
81 /// in-flight response when the user rapidly switches streams.
81 /// in-flight response when the user rapidly switches streams.
82 private(set) var latestSchemaStream: String?
82 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?
83
88
84 // Query task for cancellation
89 // Query task for cancellation
85 private var queryTask: Task<Void, Never>?
90 private var queryTask: Task<Void, Never>?
@@ -415,6 +420,7 @@ final class QueryViewModel {
415 // the loading indicator during this query).
420 // the loading indicator during this query).
416 queryGeneration &+= 1
421 queryGeneration &+= 1
417 let generation = queryGeneration
422 let generation = queryGeneration
423+ latestQueryStream = stream
418
424
419 isLoading = true
425 isLoading = true
420 errorMessage = nil
426 errorMessage = nil
@@ -442,6 +448,9 @@ final class QueryViewModel {
442 // cancellation landing just after the check above would otherwise
448 // cancellation landing just after the check above would otherwise
443 // let a stale query clobber the newer one's results/columns.
449 // let a stale query clobber the newer one's results/columns.
444 guard !Task.isCancelled else { return }
450 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 }
445
454
446 results = queryResults
455 results = queryResults
447 // Drop any prior row selection: the new result set reassigns row
456 // Drop any prior row selection: the new result set reassigns row
@@ -484,6 +493,9 @@ final class QueryViewModel {
484 return
493 return
485 } catch {
494 } catch {
486 guard !Task.isCancelled else { return }
495 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 }
487 errorMessage = ParseableError.userFriendlyMessage(for: error)
499 errorMessage = ParseableError.userFriendlyMessage(for: error)
488 if let serverError = error as? ParseableError,
500 if let serverError = error as? ParseableError,
489 case .serverError(_, let msg) = serverError,
501 case .serverError(_, let msg) = serverError,
@@ -682,6 +694,10 @@ final class QueryViewModel {
682 errorRange = nil
694 errorRange = nil
683 resultsTruncated = false
695 resultsTruncated = false
684 schemaFields = []
696 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
685 }
701 }
686
702
687 /// Modifies `sqlQuery` to add a column-value filter condition.
703 /// Modifies `sqlQuery` to add a column-value filter condition.
ParseableViewer/Views/SyntaxHighlighting.swift+5−1
@@ -52,7 +52,11 @@ enum SQLSyntaxHighlighter {
52 private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
52 private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
53 private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
53 private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
54 private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
54 private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
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.
55− private static let numberRegex = try! NSRegularExpression(pattern: "\\b\\d+(?:\\.\\d+)?\\b", options: [])
59+ private static let numberRegex = try! NSRegularExpression(pattern: "(?<![\\w.])(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?", options: [])
56 private static let keywordRegex: NSRegularExpression = {
60 private static let keywordRegex: NSRegularExpression = {
57 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
61 let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
58 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
62 return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
ParseableViewerTests/LiveTailViewModelTests.swift+14−1
@@ -404,10 +404,23 @@ final class LiveTailViewModelTests: XCTestCase {
404 XCTAssertEqual(vm.columnFilters.count, 1)
404 XCTAssertEqual(vm.columnFilters.count, 1)
405 }
405 }
406
406
407− func testAddFilterDifferentValueIsAdditive() {
407+ func testAddFilterSameColumnIncludeReplaces() {
408 let vm = LiveTailViewModel()
408 let vm = LiveTailViewModel()
409 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
409 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.
410 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
413 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)
411 XCTAssertEqual(vm.columnFilters.count, 2)
424 XCTAssertEqual(vm.columnFilters.count, 2)
412 }
425 }
413
426