git.iko.soy/parseable-macos
on master help sign in
Add cell context menu for filtering by column value (#10)

Right-clicking a cell in the query results table now offers "Filter by This Value" and "Exclude This Value" options. Selecting either modifies the SQL query to add a WHERE condition (= or <>) for that column/value and automatically re-executes the query. NULL values use IS NULL / IS NOT NULL syntax. Column names and string values are properly escaped. https://claude.ai/code/session_01LVDyKhQzsoagFW4qcr52Bf Co-authored-by: Claude <noreply@anthropic.com>

commitc531ff6c1a571a0e0c098e11611aa4e6ceaa7ff8 authoriko <mail@iko.soy> dateSun 15 Feb 2026, 12:53 (8 months ago) parenta395908
6 files changed+176−2
ParseableViewer/Models/JSONValue.swift+13 ParseableViewer/ViewModels/QueryViewModel.swift+37 ParseableViewer/Views/LogTableView.swift+13−1 ParseableViewer/Views/QueryView.swift+10−1 ParseableViewerTests/JSONValueTests.swift+35 ParseableViewerTests/QueryViewModelTests.swift+68
ParseableViewer/Models/JSONValue.swift+13
@@ -75,6 +75,19 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
7575 }
7676 }
7777
78+ /// Returns a SQL literal representation of this value for use in WHERE clauses.
79+ var sqlLiteral: String {
80+ switch self {
81+ case .null: return "NULL"
82+ case .bool(let v): return v ? "true" : "false"
83+ case .int(let v): return String(v)
84+ case .double(let v): return String(v)
85+ case .string(let v): return "'\(v.replacingOccurrences(of: "'", with: "''"))'"
86+ case .array, .object:
87+ return "'\(exportString.replacingOccurrences(of: "'", with: "''"))'"
88+ }
89+ }
90+
7891 var isScalar: Bool {
7992 switch self {
8093 case .array, .object: return false
ParseableViewer/ViewModels/QueryViewModel.swift+37
@@ -284,6 +284,43 @@ final class QueryViewModel {
284284 resultsTruncated = false
285285 }
286286
287+ /// Modifies `sqlQuery` to add a column-value filter condition.
288+ /// When `exclude` is true, the condition excludes the value; otherwise it includes only matching rows.
289+ func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
290+ let escapedCol = Self.escapeSQLIdentifier(column)
291+ let condition: String
292+ if let value, value != .null {
293+ let literal = value.sqlLiteral
294+ condition = exclude ? "\(escapedCol) <> \(literal)" : "\(escapedCol) = \(literal)"
295+ } else {
296+ condition = exclude ? "\(escapedCol) IS NOT NULL" : "\(escapedCol) IS NULL"
297+ }
298+
299+ var sql = sqlQuery.trimmingCharacters(in: .whitespacesAndNewlines)
300+ if sql.isEmpty { return }
301+
302+ let wherePattern = #"(?i)\bWHERE\b"#
303+ let tailPattern = #"(?i)\b(ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT)\b"#
304+
305+ if let whereRange = sql.range(of: wherePattern, options: .regularExpression) {
306+ let afterWhere = whereRange.upperBound
307+ let searchRange = afterWhere..<sql.endIndex
308+ if let tailRange = sql.range(of: tailPattern, options: .regularExpression, range: searchRange) {
309+ sql.insert(contentsOf: "AND \(condition) ", at: tailRange.lowerBound)
310+ } else {
311+ sql += " AND \(condition)"
312+ }
313+ } else {
314+ if let tailRange = sql.range(of: tailPattern, options: .regularExpression) {
315+ sql.insert(contentsOf: "WHERE \(condition) ", at: tailRange.lowerBound)
316+ } else {
317+ sql += " WHERE \(condition)"
318+ }
319+ }
320+
321+ sqlQuery = sql
322+ }
323+
287324 /// Sets the default query for the given stream, returning `true` if the
288325 /// query text was replaced (i.e. the user hadn't customized it).
289326 @discardableResult
ParseableViewer/Views/LogTableView.swift+13−1
@@ -53,6 +53,7 @@ struct LogTableView: View {
5353 let records: [LogRecord]
5454 let columns: [String]
5555 @Binding var selectedRecord: LogRecord?
56+ var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
5657 @State private var sortColumn: String?
5758 @State private var sortAscending = false
5859 @State private var selectedIndex: Int?
@@ -81,7 +82,8 @@ struct LogTableView: View {
8182 columns: columns,
8283 columnWidths: columnWidths,
8384 isSelected: selectedIndex == index,
84− isAlternate: index % 2 == 1
85+ isAlternate: index % 2 == 1,
86+ onCellFilter: onCellFilter
8587 )
8688 .onTapGesture {
8789 selectedIndex = index
@@ -258,6 +260,7 @@ struct LogRowView: View {
258260 let columnWidths: [String: CGFloat]
259261 let isSelected: Bool
260262 let isAlternate: Bool
263+ var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
261264
262265 var body: some View {
263266 HStack(spacing: 0) {
@@ -280,6 +283,15 @@ struct LogRowView: View {
280283 NSPasteboard.general.clearContents()
281284 _ = NSPasteboard.general.setString(column, forType: .string)
282285 }
286+ if let onCellFilter {
287+ Divider()
288+ Button("Filter by This Value") {
289+ onCellFilter(column, value, false)
290+ }
291+ Button("Exclude This Value") {
292+ onCellFilter(column, value, true)
293+ }
294+ }
283295 }
284296 }
285297 }
ParseableViewer/Views/QueryView.swift+10−1
@@ -186,7 +186,16 @@ struct QueryView: View {
186186 LogTableView(
187187 records: viewModel.filteredResults,
188188 columns: viewModel.columns,
189− selectedRecord: $vm.selectedLogEntry
189+ selectedRecord: $vm.selectedLogEntry,
190+ onCellFilter: { column, value, exclude in
191+ viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
192+ Task {
193+ await viewModel.executeQuery(
194+ client: appState.client,
195+ stream: appState.selectedStream
196+ )
197+ }
198+ }
190199 )
191200 }
192201 }
ParseableViewerTests/JSONValueTests.swift+35
@@ -255,6 +255,41 @@ final class JSONValueTests: XCTestCase {
255255 XCTAssertEqual(records[0]["level"], .string("error"))
256256 }
257257
258+ // MARK: - sqlLiteral
259+
260+ func testSqlLiteralNull() {
261+ XCTAssertEqual(JSONValue.null.sqlLiteral, "NULL")
262+ }
263+
264+ func testSqlLiteralBool() {
265+ XCTAssertEqual(JSONValue.bool(true).sqlLiteral, "true")
266+ XCTAssertEqual(JSONValue.bool(false).sqlLiteral, "false")
267+ }
268+
269+ func testSqlLiteralInt() {
270+ XCTAssertEqual(JSONValue.int(42).sqlLiteral, "42")
271+ XCTAssertEqual(JSONValue.int(-7).sqlLiteral, "-7")
272+ }
273+
274+ func testSqlLiteralDouble() {
275+ XCTAssertEqual(JSONValue.double(3.14).sqlLiteral, "3.14")
276+ }
277+
278+ func testSqlLiteralString() {
279+ XCTAssertEqual(JSONValue.string("hello").sqlLiteral, "'hello'")
280+ }
281+
282+ func testSqlLiteralStringEscapesSingleQuotes() {
283+ XCTAssertEqual(JSONValue.string("it's").sqlLiteral, "'it''s'")
284+ }
285+
286+ func testSqlLiteralArray() {
287+ let arr = JSONValue.array([.int(1), .int(2)])
288+ let literal = arr.sqlLiteral
289+ XCTAssertTrue(literal.hasPrefix("'"))
290+ XCTAssertTrue(literal.hasSuffix("'"))
291+ }
292+
258293 // MARK: - LogRecord as typealias
259294
260295 func testLogRecordDecoding() throws {
ParseableViewerTests/QueryViewModelTests.swift+68
@@ -201,5 +201,73 @@ final class QueryViewModelTests: XCTestCase {
201201 vm.setDefaultQuery(stream: "stream\"name")
202202 XCTAssertTrue(vm.sqlQuery.contains("\"stream\"\"name\""))
203203 }
204+
205+ // MARK: - Column filter
206+
207+ func testAddColumnFilterIncludeStringValue() {
208+ let vm = QueryViewModel()
209+ vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
210+ vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
211+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
212+ XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
213+ }
214+
215+ func testAddColumnFilterExcludeStringValue() {
216+ let vm = QueryViewModel()
217+ vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC LIMIT 1000"
218+ vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
219+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'debug'"))
220+ XCTAssertTrue(vm.sqlQuery.contains("ORDER BY"))
221+ }
222+
223+ func testAddColumnFilterNullValue() {
224+ let vm = QueryViewModel()
225+ vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC"
226+ vm.addColumnFilter(column: "tag", value: nil, exclude: false)
227+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"tag\" IS NULL"))
228+ }
229+
230+ func testAddColumnFilterExcludeNullValue() {
231+ let vm = QueryViewModel()
232+ vm.sqlQuery = "SELECT * FROM \"logs\" ORDER BY p_timestamp DESC"
233+ vm.addColumnFilter(column: "tag", value: .null, exclude: true)
234+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"tag\" IS NOT NULL"))
235+ }
236+
237+ func testAddColumnFilterAppendsToExistingWhere() {
238+ let vm = QueryViewModel()
239+ vm.sqlQuery = "SELECT * FROM \"logs\" WHERE \"level\" = 'error' ORDER BY p_timestamp DESC"
240+ vm.addColumnFilter(column: "host", value: .string("web-1"), exclude: false)
241+ XCTAssertTrue(vm.sqlQuery.contains("AND \"host\" = 'web-1'"))
242+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" = 'error'"))
243+ }
244+
245+ func testAddColumnFilterIntValue() {
246+ let vm = QueryViewModel()
247+ vm.sqlQuery = "SELECT * FROM \"logs\""
248+ vm.addColumnFilter(column: "status", value: .int(200), exclude: false)
249+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"status\" = 200"))
250+ }
251+
252+ func testAddColumnFilterNoOpOnEmptyQuery() {
253+ let vm = QueryViewModel()
254+ vm.sqlQuery = ""
255+ vm.addColumnFilter(column: "level", value: .string("info"), exclude: false)
256+ XCTAssertEqual(vm.sqlQuery, "")
257+ }
258+
259+ func testAddColumnFilterQueryWithoutOrderBy() {
260+ let vm = QueryViewModel()
261+ vm.sqlQuery = "SELECT * FROM \"logs\""
262+ vm.addColumnFilter(column: "level", value: .string("warn"), exclude: true)
263+ XCTAssertTrue(vm.sqlQuery.contains("WHERE \"level\" <> 'warn'"))
264+ }
265+
266+ func testAddColumnFilterEscapesSingleQuotesInValue() {
267+ let vm = QueryViewModel()
268+ vm.sqlQuery = "SELECT * FROM \"logs\""
269+ vm.addColumnFilter(column: "msg", value: .string("it's a test"), exclude: false)
270+ XCTAssertTrue(vm.sqlQuery.contains("'it''s a test'"))
271+ }
204272}
205273