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
648e0b8iko
ParseableViewer/App/AppState.swift+4
@@ -292,6 +292,10 @@ final class AppState {
292 selectedStream = nil
292 selectedStream = nil
293 streamSearchText = ""
293 streamSearchText = ""
294 currentTab = .query
294 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
295 Task { await connect(to: connection) }
299 Task { await connect(to: connection) }
296 }
300 }
297 }
301 }
ParseableViewer/Models/JSONValue.swift+33−5
@@ -218,16 +218,44 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
218 // the same fallback `LogStream.<` already applies.
218 // the same fallback `LogStream.<` already applies.
219 let order = a.localizedStandardCompare(b)
219 let order = a.localizedStandardCompare(b)
220 return order == .orderedAscending || (order == .orderedSame && a < b)
220 return order == .orderedAscending || (order == .orderedSame && a < b)
221− case (.array, .array), (.object, .object):
221+ case (.array(let a), .array(let b)):
222− // Same rank, structured: equal values must not be ordered; otherwise
222+ return arrayLessThan(a, b)
223− // use a deterministic serialization tiebreaker.
223+ case (.object(let a), .object(let b)):
224− if lhs == rhs { return false }
224+ return objectLessThan(a, b)
225− return lhs.compactJSON < rhs.compactJSON
226 default:
225 default:
227 return false
226 return false
228 }
227 }
229 }
228 }
230
229
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+
231 /// Total ordering for doubles that keeps `sort()` from trapping on NaN
259 /// Total ordering for doubles that keeps `sort()` from trapping on NaN
232 /// (IEEE comparisons with NaN are always false, which violates the strict
260 /// (IEEE comparisons with NaN are always false, which violates the strict
233 /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
261 /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
ParseableViewer/Services/ParseableClient.swift+9−1
@@ -175,7 +175,11 @@ final class ParseableClient: Sendable {
175 }
175 }
176
176
177 guard (200...299).contains(httpResponse.statusCode) else {
177 guard (200...299).contains(httpResponse.statusCode) else {
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.
178− let message = String(data: data, encoding: .utf8) ?? "Unknown error"
180+ let raw = (String(data: data, encoding: .utf8) ?? "")
181+ .trimmingCharacters(in: .whitespacesAndNewlines)
182+ let message = raw.isEmpty ? "Unknown error" : String(raw.prefix(500))
179 throw ParseableError.serverError(httpResponse.statusCode, message)
183 throw ParseableError.serverError(httpResponse.statusCode, message)
180 }
184 }
181
185
@@ -304,6 +308,10 @@ final class ParseableClient: Sendable {
304 sniff = sniff.dropFirst(3)
308 sniff = sniff.dropFirst(3)
305 }
309 }
306 let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
310 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 [] }
307 if firstByte == 0x7B { // '{'
315 if firstByte == 0x7B { // '{'
308 do {
316 do {
309 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
317 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
ParseableViewer/ViewModels/QueryViewModel.swift+10−2
@@ -667,8 +667,16 @@ final class QueryViewModel {
667 let head = sql[..<whereEnd]
667 let head = sql[..<whereEnd]
668 let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
668 let body = sql[whereEnd..<bodyEnd].trimmingCharacters(in: .whitespacesAndNewlines)
669 let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
669 let tail = sql[bodyEnd...].trimmingCharacters(in: .whitespacesAndNewlines)
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 {
670− let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
677+ let wrappedBody = bodyHasTopLevelOr ? "(\(body))" : body
671− sql = "\(head) \(wrappedBody) AND \(condition)"
678+ sql = "\(head) \(wrappedBody) AND \(condition)"
679+ }
672 if !tail.isEmpty {
680 if !tail.isEmpty {
673 sql += " \(tail)"
681 sql += " \(tail)"
674 }
682 }