From 0b20122bb0131cc4979c7aff1662259e29cd3a6a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 29 Sep 2026 20:11:40 +0700 Subject: [PATCH 1/2] fix(plugins): take the filter-bar BETWEEN lower bound off the joined value on Typesense, Weaviate and BigQuery --- CHANGELOG.md | 1 + .../WeaviateFilterBuilder.swift | 13 +++- .../WeaviateFilterAndPathTests.swift | 30 ++++++++- .../BigQueryQueryBuilder.swift | 9 ++- .../TypesenseFilterBuilder.swift | 11 +++- .../Plugins/BigQueryQueryBuilderTests.swift | 65 +++++++++++++++---- .../Plugins/TypesenseDriverTests.swift | 31 ++++++++- 7 files changed, 142 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40eb3555b3..25fa05db4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS row editor saving the placeholder of a long text or binary value over the full value. - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - **Local only** connections taking edits and deletions made on another device. +- Filter-bar BETWEEN refused on Typesense and Weaviate, and given the wrong lower bound on BigQuery. ## [0.76.1] - 2026-09-29 diff --git a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateFilterBuilder.swift b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateFilterBuilder.swift index d9235f1578..8d5edb30d1 100644 --- a/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateFilterBuilder.swift +++ b/Packages/TableProCore/Sources/TableProWeaviateCore/WeaviateFilterBuilder.swift @@ -238,7 +238,11 @@ public enum WeaviateFilterBuilder { throw WeaviateFilterError.missingUpperBound(column: column) } let lower = try comparison( - "GreaterThanEqual", path: path, column: column, value: filter.value, kind: kind + "GreaterThanEqual", + path: path, + column: column, + value: lowerBound(of: filter.value, upperBound: upperBound), + kind: kind ) let upper = try comparison( "LessThanEqual", path: path, column: column, value: upperBound, kind: kind @@ -246,6 +250,13 @@ public enum WeaviateFilterBuilder { return "{ operator: And operands: [\(lower) \(upper)] }" } + private static func lowerBound(of joinedValue: String, upperBound: String) -> String { + let joinedSuffix = ("," + upperBound).unicodeScalars + let scalars = joinedValue.unicodeScalars + guard scalars.reversed().starts(with: joinedSuffix.reversed()) else { return joinedValue } + return String(scalars.dropLast(joinedSuffix.count)) + } + private static func negated(_ operand: String) -> String { "{ operator: Not operands: [\(operand)] }" } diff --git a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift index ba119e161c..78cc4eab39 100644 --- a/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift +++ b/Packages/TableProCore/Tests/TableProWeaviateCoreTests/WeaviateFilterAndPathTests.swift @@ -100,7 +100,7 @@ struct WeaviateFilterOperatorTests { @Test("BETWEEN becomes a bounded And and needs its upper bound") func betweenBounds() throws { - let clause = try operand("wordCount", "BETWEEN", "5", second: "10") + let clause = try operand("wordCount", "BETWEEN", "5,10", second: "10") #expect(clause.hasPrefix("{ operator: And operands: [")) #expect(clause.contains("operator: GreaterThanEqual valueInt: 5")) #expect(clause.contains("operator: LessThanEqual valueInt: 10")) @@ -110,6 +110,26 @@ struct WeaviateFilterOperatorTests { } } + @Test("BETWEEN takes its lower bound off the value the app joins with the upper bound") + func betweenReadsLowerBoundOffJoinedValue() throws { + let numbers = try operand("ratio", "BETWEEN", "0.25,0.75", second: "0.75") + #expect(numbers.contains("operator: GreaterThanEqual valueNumber: 0.25")) + #expect(numbers.contains("operator: LessThanEqual valueNumber: 0.75")) + + let dates = try operand( + "published", "BETWEEN", "2024-01-01T00:00:00Z,2024-12-31T00:00:00Z", second: "2024-12-31T00:00:00Z" + ) + #expect(dates.contains("operator: GreaterThanEqual valueDate: \"2024-01-01T00:00:00Z\"")) + #expect(dates.contains("operator: LessThanEqual valueDate: \"2024-12-31T00:00:00Z\"")) + } + + @Test("BETWEEN refuses a lower bound whose last scalar joins the comma, naming that bound alone") + func betweenRefusesPrependScalarLowerBoundAlone() { + #expect(throws: WeaviateFilterError.notANumber(column: "wordCount", value: "5\u{0600}")) { + _ = try operand("wordCount", "BETWEEN", "5\u{0600},10", second: "10") + } + } + @Test("IS NULL maps to IsNull in both directions") func nullOperators() throws { #expect(try operand("title", "IS NULL", "").contains("operator: IsNull valueBoolean: true")) @@ -224,12 +244,16 @@ struct WeaviateSortTests { offset: 0, limit: 25, sorts: [], - filters: [WeaviateFilterSpec(column: "wordCount", op: "BETWEEN", value: "5", secondValue: "10")], + filters: [WeaviateFilterSpec(column: "wordCount", op: "BETWEEN", value: "5,10", secondValue: "10")], logicMode: "AND", propertyNames: ["uuid"] ) let parsed = try #require(WeaviateBrowseQuery.parse(encoded)) - #expect(parsed.filters.first?.secondValue == "10") + let filter = try #require(parsed.filters.first) + #expect(filter.secondValue == "10") + let clause = try WeaviateFilterBuilder.operand(for: filter, types: articleTypes) + #expect(clause.contains("operator: GreaterThanEqual valueInt: 5")) + #expect(clause.contains("operator: LessThanEqual valueInt: 10")) } } diff --git a/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift b/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift index 7cb46c5b00..d74d60d33f 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift @@ -428,7 +428,7 @@ internal struct BigQueryQueryBuilder { ) -> String? { let bounds: (lower: String, upper: String) if let secondValue = filter.secondValue { - bounds = (filter.value, secondValue) + bounds = (lowerBound(of: filter.value, upperBound: secondValue), secondValue) } else { let parts = filter.value.split(separator: ",", maxSplits: 1).map(String.init) guard parts.count == 2 else { return nil } @@ -440,6 +440,13 @@ internal struct BigQueryQueryBuilder { return "\(column) BETWEEN \(literal(lower, kind: kind)) AND \(literal(upper, kind: kind))" } + private static func lowerBound(of joinedValue: String, upperBound: String) -> String { + let joinedSuffix = ("," + upperBound).unicodeScalars + let scalars = joinedValue.unicodeScalars + guard scalars.reversed().starts(with: joinedSuffix.reversed()) else { return joinedValue } + return String(scalars.dropLast(joinedSuffix.count)) + } + private static func comparisonClause( _ column: String, _ operatorText: String, diff --git a/Plugins/TypesenseDriverPlugin/TypesenseFilterBuilder.swift b/Plugins/TypesenseDriverPlugin/TypesenseFilterBuilder.swift index 094749e532..38d7596e8a 100644 --- a/Plugins/TypesenseDriverPlugin/TypesenseFilterBuilder.swift +++ b/Plugins/TypesenseDriverPlugin/TypesenseFilterBuilder.swift @@ -157,11 +157,20 @@ enum TypesenseFilterBuilder { guard let upperBound = spec.secondValue, !upperBound.isEmpty else { throw TypesenseFilterError.missingUpperBound(column: column) } - let lower = try numeric(spec.value, column: column, op: "BETWEEN", field: field) + let lower = try numeric( + lowerBound(of: spec.value, upperBound: upperBound), column: column, op: "BETWEEN", field: field + ) let upper = try numeric(upperBound, column: column, op: "BETWEEN", field: field) return "\(column):[\(lower)..\(upper)]" } + private static func lowerBound(of joinedValue: String, upperBound: String) -> String { + let joinedSuffix = ("," + upperBound).unicodeScalars + let scalars = joinedValue.unicodeScalars + guard scalars.reversed().starts(with: joinedSuffix.reversed()) else { return joinedValue } + return String(scalars.dropLast(joinedSuffix.count)) + } + private static func list(_ value: String, column: String, field: TypesenseField?) throws -> String { try value .split(separator: ",") diff --git a/TableProTests/Plugins/BigQueryQueryBuilderTests.swift b/TableProTests/Plugins/BigQueryQueryBuilderTests.swift index a65da8a78a..3d93d94435 100644 --- a/TableProTests/Plugins/BigQueryQueryBuilderTests.swift +++ b/TableProTests/Plugins/BigQueryQueryBuilderTests.swift @@ -2,6 +2,8 @@ import Foundation import TableProPluginKit import Testing +@testable import TablePro + struct BigQueryQueryBuilderBrowseTests { @Test("Browse query returns tagged string") func browseReturnsTag() { @@ -275,15 +277,51 @@ struct BigQueryQueryBuilderSQLTests { #expect(sql.contains("WHERE (`a` = 1 OR `b` = 2) AND (CAST(`c` AS STRING) LIKE '%z%')")) } - @Test("BETWEEN uses the separate upper bound") - func betweenUsesSecondValue() { + @Test("A filter-bar BETWEEN takes its lower bound off the value the app joins with the upper bound") + func betweenFromAppEncoding() { + let filter = TableFilter( + columnName: "n", filterOperator: .between, value: "1", secondValue: "9" + ).asPluginQueryFilter + #expect(filter.value == "1,9") + let sql = BigQueryQueryBuilder.buildSQL( - from: params(filters: [ - BigQueryFilterSpec(column: "n", op: "BETWEEN", value: "1", kind: "integer", secondValue: "9") - ]), + from: params(filters: [BigQueryFilterSpec(filter, kind: .integer)]), + projectId: "proj" + ) + #expect(sql.contains("WHERE (`n` BETWEEN 1 AND 9)")) + } + + @Test("A filter-bar BETWEEN keeps a comma inside a bound") + func betweenFromAppEncodingWithComma() { + let filter = TableFilter( + columnName: "name", filterOperator: .between, value: "Smith, John", secondValue: "Zed, Ann" + ).asPluginQueryFilter + let sql = BigQueryQueryBuilder.buildSQL( + from: params(filters: [BigQueryFilterSpec(filter, kind: .text)]), + projectId: "proj" + ) + #expect(sql.contains("WHERE (`name` BETWEEN 'Smith, John' AND 'Zed, Ann')")) + } + + @Test("A filter-bar BETWEEN keeps a lower bound whose last scalar joins the comma into one character") + func betweenFromAppEncodingWithPrependScalar() { + let filter = TableFilter( + columnName: "name", filterOperator: .between, value: "x\u{0600}", secondValue: "y" + ).asPluginQueryFilter + let sql = BigQueryQueryBuilder.buildSQL( + from: params(filters: [BigQueryFilterSpec(filter, kind: .text)]), projectId: "proj" ) - #expect(sql.contains("`n` BETWEEN 1 AND 9")) + #expect(sql.contains("WHERE (`name` BETWEEN 'x\u{0600}' AND 'y')")) + } + + @Test("A BETWEEN with no separate upper bound splits the joined value") + func betweenFromJoinedValueAlone() { + let sql = BigQueryQueryBuilder.buildSQL( + from: params(filters: [BigQueryFilterSpec(column: "n", op: "BETWEEN", value: "1, 9", kind: "integer")]), + projectId: "proj" + ) + #expect(sql.contains("WHERE (`n` BETWEEN 1 AND 9)")) } @Test("A raw SQL filter is wrapped in parentheses") @@ -321,15 +359,20 @@ struct BigQueryQueryBuilderTagColumnTests { } @Test("Filter tags carry the column names and the upper bound") - func filterCarriesColumns() { + func filterCarriesColumns() throws { + let filter = TableFilter( + columnName: "n", filterOperator: .between, value: "1", secondValue: "5" + ).asPluginQueryFilter let query = BigQueryQueryBuilder.encodeFilteredQuery( table: "t", dataset: "d", - filters: [PluginQueryFilter(column: "n", op: "BETWEEN", value: "1", secondValue: "5", elementScope: nil)], + filters: [filter], logicMode: "AND", sortColumns: [], limit: 10, offset: 0, columns: ["n"] ) - let params = BigQueryQueryBuilder.decode(query) - #expect(params?.columns == ["n"]) - #expect(params?.filters?.first?.secondValue == "5") + let params = try #require(BigQueryQueryBuilder.decode(query)) + #expect(params.columns == ["n"]) + #expect(params.filters?.first?.secondValue == "5") + let sql = BigQueryQueryBuilder.buildSQL(from: params, projectId: "p") + #expect(sql.contains("WHERE (`n` BETWEEN 1 AND 5)")) } @Test("A tag written before column names existed still decodes") diff --git a/TableProTests/Plugins/TypesenseDriverTests.swift b/TableProTests/Plugins/TypesenseDriverTests.swift index 1b1e81db6f..bf0e516b43 100644 --- a/TableProTests/Plugins/TypesenseDriverTests.swift +++ b/TableProTests/Plugins/TypesenseDriverTests.swift @@ -9,6 +9,8 @@ import Foundation import TableProPluginKit import Testing +@testable import TablePro + private func field( _ name: String, _ type: String, @@ -250,7 +252,34 @@ struct TypesenseFilterBuilderTests { #expect(try clause("year", ">", "1900") == "year:>1900") #expect(try clause("year", ">=", "1900") == "year:>=1900") #expect(try clause("rating", "<", "4.5") == "rating:<4.5") - #expect(try clause("year", "BETWEEN", "1937", second: "1965") == "year:[1937..1965]") + #expect(try clause("year", "BETWEEN", "1937,1965", second: "1965") == "year:[1937..1965]") + } + + @Test("A filter-bar BETWEEN takes its lower bound off the value the app joins with the upper bound") + func buildsRangeFromAppEncoding() throws { + let filter = TableFilter( + columnName: "year", filterOperator: .between, value: "1937", secondValue: "1965" + ).asPluginQueryFilter + #expect(filter.value == "1937,1965") + + let clause = try TypesenseFilterBuilder.clause(for: TypesenseFilterSpec(filter), fields: booksFields) + #expect(clause == "year:[1937..1965]") + } + + @Test("A filter-bar BETWEEN survives the search tag round-trip") + func buildsRangeAfterSearchTagRoundTrip() throws { + let filter = TableFilter( + columnName: "rating", filterOperator: .between, value: "3.5", secondValue: "4.5" + ).asPluginQueryFilter + let tag = TypesenseQueryBuilder.encodeSearch( + collection: "books", offset: 0, limit: 10, sorts: [], + filters: TypesenseFilterBuilder.specs(from: [filter]), logicMode: "AND" + ) + let parsed = try #require(TypesenseQueryBuilder.parseSearch(tag)) + let expression = try TypesenseFilterBuilder.expression( + filters: parsed.filters, logicMode: parsed.logicMode, fields: booksFields + ) + #expect(expression == "rating:[3.5..4.5]") } @Test("A comparison on a string field is refused rather than silently matching nothing") From 4c3f773ba90d9ce99b4ea2f395d99dbca7b98c3d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 29 Sep 2026 20:23:54 +0700 Subject: [PATCH 2/2] fix(plugin-cassandra): state the Match any limit without naming a control MCP clients do not have --- CHANGELOG.md | 1 + .../CassandraDriverPlugin/CassandraBrowseStatement.swift | 2 +- TablePro/Resources/Localizable.xcstrings | 2 +- TableProTests/Plugins/CassandraBrowseStatementTests.swift | 8 ++++++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25fa05db4b..4d59c16248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - **Local only** connections taking edits and deletions made on another device. - Filter-bar BETWEEN refused on Typesense and Weaviate, and given the wrong lower bound on BigQuery. +- Cassandra filter error telling MCP clients to use a Match All control they do not have. ## [0.76.1] - 2026-09-29 diff --git a/Plugins/CassandraDriverPlugin/CassandraBrowseStatement.swift b/Plugins/CassandraDriverPlugin/CassandraBrowseStatement.swift index f3aa13cf5d..a3636fc18d 100644 --- a/Plugins/CassandraDriverPlugin/CassandraBrowseStatement.swift +++ b/Plugins/CassandraDriverPlugin/CassandraBrowseStatement.swift @@ -64,7 +64,7 @@ struct CassandraBrowseRefusal: Error, Equatable, PluginDriverError { )) static let matchAny = CassandraBrowseRefusal(pluginErrorMessage: String( - localized: "Cassandra filters cannot match any one of several conditions. Use Match All." + localized: "Cassandra filters can only require every condition to match." )) static let emptyList = CassandraBrowseRefusal(pluginErrorMessage: String( diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4732c63b49..811976a9b4 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -27839,7 +27839,7 @@ "Cassandra columns have no default value." : { }, - "Cassandra filters cannot match any one of several conditions. Use Match All." : { + "Cassandra filters can only require every condition to match." : { }, "Cassandra orders rows only by clustering columns within one partition, so a table cannot be sorted by a column." : { diff --git a/TableProTests/Plugins/CassandraBrowseStatementTests.swift b/TableProTests/Plugins/CassandraBrowseStatementTests.swift index 6a1647a0ae..7e3d9015bf 100644 --- a/TableProTests/Plugins/CassandraBrowseStatementTests.swift +++ b/TableProTests/Plugins/CassandraBrowseStatementTests.swift @@ -153,6 +153,14 @@ struct CassandraBrowseStatementTests { #expect(statement.window.refusal == CassandraBrowseRefusal.matchAny.pluginErrorMessage) } + @Test("The Match Any refusal states the limit without naming a control, since only an MCP client can reach it") + func matchAnyRefusalNamesNoControl() { + let message = CassandraBrowseRefusal.matchAny.pluginErrorMessage + + #expect(!message.localizedCaseInsensitiveContains("Match All")) + #expect(!message.localizedCaseInsensitiveContains("Match Any")) + } + @Test("Match Any over a single condition is just that condition") func matchAnyOfOneRuns() { let statement = browse(filters: [filter("a", "=", "1")], matchAll: false)