Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ 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.
- Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off.
- **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.
- Japanese, Chinese and Korean text in CSV, TSV and SQL files opening as garbled characters.
- Every row of a UTF-16 data file rewritten on save.
- Latin-1 and Windows-1252 SQL dumps importing double-encoded text through their own `SET NAMES`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,25 @@ 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
)
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)] }"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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"))
Expand Down Expand Up @@ -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"))
}
}

Expand Down
9 changes: 8 additions & 1 deletion Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion Plugins/TypesenseDriverPlugin/TypesenseFilterBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ",")
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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." : {
Expand Down
65 changes: 54 additions & 11 deletions TableProTests/Plugins/BigQueryQueryBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Foundation
import TableProPluginKit
import Testing

@testable import TablePro

struct BigQueryQueryBuilderBrowseTests {
@Test("Browse query returns tagged string")
func browseReturnsTag() {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions TableProTests/Plugins/CassandraBrowseStatementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 30 additions & 1 deletion TableProTests/Plugins/TypesenseDriverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import Foundation
import TableProPluginKit
import Testing

@testable import TablePro

private func field(
_ name: String,
_ type: String,
Expand Down Expand Up @@ -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")
Expand Down
Loading