git.iko.soy/parseable-macos
on master help sign in
Fix 5 bugs: empty WHERE filter, stale server info, JSONValue ordering, whitespace response, error truncation
commit648e0b8564886ec01ab74548e2fc1e355e995b83 authoriko <mail@iko.soy> dateSun 21 Jun 2026, 14:06 (3 months ago) parent895b357
4 files changed+56−8
ParseableViewer/App/AppState.swift+4 ParseableViewer/Models/JSONValue.swift+33−5 ParseableViewer/Services/ParseableClient.swift+9−1 ParseableViewer/ViewModels/QueryViewModel.swift+10−2
ParseableViewer/App/AppState.swift+4
@@ -292,6 +292,10 @@ final class AppState {
292292 selectedStream = nil
293293 streamSearchText = ""
294294 currentTab = .query
295+ // Drop the previous server's cached `about` too — performConnect skips
296+ // its own clear (id unchanged), so without this ServerInfoView keeps
297+ // painting the old server's version/storage until the new /about lands.
298+ serverAbout = nil
295299 Task { await connect(to: connection) }
296300 }
297301 }
ParseableViewer/Models/JSONValue.swift+33−5
@@ -218,16 +218,44 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
218218 // the same fallback `LogStream.<` already applies.
219219 let order = a.localizedStandardCompare(b)
220220 return order == .orderedAscending || (order == .orderedSame && a < b)
221− case (.array, .array), (.object, .object):
222− // Same rank, structured: equal values must not be ordered; otherwise
223− // use a deterministic serialization tiebreaker.
224− if lhs == rhs { return false }
225− return lhs.compactJSON < rhs.compactJSON
221+ case (.array(let a), .array(let b)):
222+ return arrayLessThan(a, b)
223+ case (.object(let a), .object(let b)):
224+ return objectLessThan(a, b)
226225 default:
227226 return false
228227 }
229228 }
230229
230+ /// Lexicographic order over array elements using the element-level `<`/`==`.
231+ /// A serialized-string tiebreaker (`compactJSON < compactJSON`) was wrong:
232+ /// `.int(1) == .double(1.0)` are equal yet serialize as `1` vs `1.0`, so two
233+ /// non-equal values could share a serialization (or two equal ones differ),
234+ /// breaking the strict-weak-ordering `Comparable` requires. Comparing
235+ /// elements directly stays consistent with `==` (arrays are equal iff same
236+ /// length and equal elements).
237+ private static func arrayLessThan(_ a: [JSONValue], _ b: [JSONValue]) -> Bool {
238+ for (x, y) in zip(a, b) where x != y {
239+ return x < y
240+ }
241+ return a.count < b.count
242+ }
243+
244+ /// Deterministic order over objects: compare key/value pairs in sorted-key
245+ /// order. Independent of dictionary iteration order and consistent with `==`
246+ /// (objects are equal iff the same keys map to equal values).
247+ private static func objectLessThan(_ a: [String: JSONValue], _ b: [String: JSONValue]) -> Bool {
248+ let aKeys = a.keys.sorted()
249+ let bKeys = b.keys.sorted()
250+ for (ak, bk) in zip(aKeys, bKeys) {
251+ if ak != bk { return ak < bk }
252+ // Force-unwrap is safe: ak/bk come from each dictionary's own keys.
253+ let av = a[ak]!, bv = b[bk]!
254+ if av != bv { return av < bv }
255+ }
256+ return aKeys.count < bKeys.count
257+ }
258+
231259 /// Total ordering for doubles that keeps `sort()` from trapping on NaN
232260 /// (IEEE comparisons with NaN are always false, which violates the strict
233261 /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
ParseableViewer/Services/ParseableClient.swift+9−1
@@ -175,7 +175,11 @@ final class ParseableClient: Sendable {
175175 }
176176
177177 guard (200...299).contains(httpResponse.statusCode) else {
178− let message = String(data: data, encoding: .utf8) ?? "Unknown error"
178+ // A proxy/load balancer can answer with a multi-KB HTML body; surfacing
179+ // it verbatim floods the error alert. Trim and cap to a readable prefix.
180+ let raw = (String(data: data, encoding: .utf8) ?? "")
181+ .trimmingCharacters(in: .whitespacesAndNewlines)
182+ let message = raw.isEmpty ? "Unknown error" : String(raw.prefix(500))
179183 throw ParseableError.serverError(httpResponse.statusCode, message)
180184 }
181185
@@ -304,6 +308,10 @@ final class ParseableClient: Sendable {
304308 sniff = sniff.dropFirst(3)
305309 }
306310 let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
311+ // A body of only whitespace/BOM carries no records — treat it like an
312+ // empty response rather than feeding it to the array decoder, which would
313+ // throw a confusing "Unexpected query response format".
314+ guard let firstByte else { return [] }
307315 if firstByte == 0x7B { // '{'
308316 do {
309317 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
ParseableViewer/ViewModels/QueryViewModel.swift+10−2
@@ -667,8 +667,16 @@ final class QueryViewModel {
667667 let head = sql[..<whereEnd]
668668 let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
669669 let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
670− let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
671− sql = "\(head) \(wrappedBody) AND \(condition)"
670+ // An empty WHERE body (e.g. a half-written `... WHERE` with no
671+ // condition yet, or `WHERE` immediately followed by ORDER/GROUP/
672+ // LIMIT) would otherwise produce `WHERE AND <condition>` — a
673+ // dangling AND with no left operand. Drop the AND in that case.
674+ if body.isEmpty {
675+ sql = "\(head) \(condition)"
676+ } else {
677+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
678+ sql = "\(head) \(wrappedBody) AND \(condition)"
679+ }
672680 if !tail.isEmpty {
673681 sql += " \(tail)"
674682 }