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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Exporting a query's remaining rows while disconnected now reports the error instead of leaving the progress sheet up forever. (#2026)
- Stopping a query now cancels the query itself rather than whichever background metadata read finished last. (#2026)
- Reopening a window no longer loses a table tab's saved sort and page when the connection was still connecting. (#2026)
- Filtering a text column by a value that looks like a number, such as 68, now compares it as text. It used to compare as a number, which returned the wrong rows and stopped the database using the column's index. (#2029)
- Typing NULL, TRUE, or FALSE into a filter on a text column now matches that text instead of turning into the SQL keyword, so those values can be filtered for. (#2029)
- IS EMPTY on a number, date, or boolean column now checks only for NULL, instead of also comparing against an empty string, which some databases reject. (#2029)

### Changed

Expand Down
19 changes: 18 additions & 1 deletion Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,23 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
columns: [String],
limit: Int,
offset: Int
) -> String? {
buildFilteredQuery(
table: table, schema: schema, filters: filters, logicMode: logicMode,
sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:]
)
}

func buildFilteredQuery(
table: String,
schema: String?,
filters: [(column: String, op: String, value: String)],
logicMode: String,
sortColumns: [(columnIndex: Int, ascending: Bool)],
columns: [String],
limit: Int,
offset: Int,
columnKinds: [String: PluginColumnKind]
) -> String? {
let dataset: String = lock.withLock {
let ds = schema ?? _currentDataset ?? ""
Expand All @@ -555,7 +572,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send
return BigQueryQueryBuilder.encodeFilteredQuery(
table: table, dataset: dataset,
filters: filters, logicMode: logicMode,
sortColumns: sortColumns, limit: limit, offset: offset
sortColumns: sortColumns, limit: limit, offset: offset, columnKinds: columnKinds
)
}

Expand Down
48 changes: 36 additions & 12 deletions Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ internal struct BigQueryFilterSpec: Codable {
let column: String
let op: String
let value: String
var kind: String?

var columnKind: PluginColumnKind? {
guard let kind else { return nil }
return PluginColumnKind(rawValue: kind)
}
}

// MARK: - Query Builder
Expand Down Expand Up @@ -73,15 +79,20 @@ internal struct BigQueryQueryBuilder {
logicMode: String,
sortColumns: [(columnIndex: Int, ascending: Bool)],
limit: Int,
offset: Int
offset: Int,
columnKinds: [String: PluginColumnKind] = [:]
) -> String {
let params = BigQueryQueryParams(
table: table,
dataset: dataset,
sortColumns: sortColumns.map { .init(columnIndex: $0.columnIndex, ascending: $0.ascending) },
limit: limit,
offset: offset,
filters: filters.map { BigQueryFilterSpec(column: $0.column, op: $0.op, value: $0.value) },
filters: filters.map {
BigQueryFilterSpec(
column: $0.column, op: $0.op, value: $0.value, kind: columnKinds[$0.column]?.rawValue
)
},
logicMode: logicMode,
searchText: nil,
searchColumns: nil
Expand Down Expand Up @@ -250,7 +261,18 @@ internal struct BigQueryQueryBuilder {

// MARK: - Private

private static func formatFilterValue(_ value: String) -> String {
private static func formatFilterValue(_ value: String, kind: PluginColumnKind?) -> String {
guard let kind else { return legacyFormatFilterValue(value) }
return PluginSQLLiteral.escapedLiteral(
value,
kind: kind,
trueLiteral: "TRUE",
falseLiteral: "FALSE",
quote: { "'\($0.replacingOccurrences(of: "'", with: "''"))'" }
)
}

private static func legacyFormatFilterValue(_ value: String) -> String {
let lower = value.lowercased()
if lower == "true" { return "TRUE" }
if lower == "false" { return "FALSE" }
Expand Down Expand Up @@ -280,34 +302,36 @@ internal struct BigQueryQueryBuilder {
) -> String? {
let col = quoteIdentifier(filter.column)
let escaped = filter.value.replacingOccurrences(of: "'", with: "''")
let kind = filter.columnKind
let isNullKeyword = filter.value.lowercased() == "null" && !PluginSQLLiteral.isKnownTextLike(kind)

switch filter.op.uppercased() {
case "=":
if filter.value.lowercased() == "null" {
if isNullKeyword {
return "\(col) IS NULL"
}
return "\(col) = \(formatFilterValue(filter.value))"
return "\(col) = \(formatFilterValue(filter.value, kind: kind))"
case "!=", "<>":
if filter.value.lowercased() == "null" {
if isNullKeyword {
return "\(col) IS NOT NULL"
}
return "\(col) != \(formatFilterValue(filter.value))"
return "\(col) != \(formatFilterValue(filter.value, kind: kind))"
case ">":
return "\(col) > \(formatFilterValue(filter.value))"
return "\(col) > \(formatFilterValue(filter.value, kind: kind))"
case ">=":
return "\(col) >= \(formatFilterValue(filter.value))"
return "\(col) >= \(formatFilterValue(filter.value, kind: kind))"
case "<":
return "\(col) < \(formatFilterValue(filter.value))"
return "\(col) < \(formatFilterValue(filter.value, kind: kind))"
case "<=":
return "\(col) <= \(formatFilterValue(filter.value))"
return "\(col) <= \(formatFilterValue(filter.value, kind: kind))"
case "LIKE":
return "\(col) LIKE '\(escaped)'"
case "NOT LIKE":
return "\(col) NOT LIKE '\(escaped)'"
case "IN", "NOT IN":
let values = filter.value.split(separator: ",").map { val in
let trimmed = val.trimmingCharacters(in: .whitespaces)
return formatFilterValue(trimmed)
return formatFilterValue(trimmed, kind: kind)
}
return "\(col) \(filter.op.uppercased()) (\(values.joined(separator: ", ")))"
case "IS NULL":
Expand Down
35 changes: 27 additions & 8 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -715,12 +715,30 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
columns: [String],
limit: Int,
offset: Int
) -> String? {
buildFilteredQuery(
table: table, schema: schema, filters: filters, logicMode: logicMode,
sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:]
)
}

func buildFilteredQuery(
table: String,
schema: String?,
filters: [(column: String, op: String, value: String)],
logicMode: String,
sortColumns: [(columnIndex: Int, ascending: Bool)],
columns: [String],
limit: Int,
offset: Int,
columnKinds: [String: PluginColumnKind]
) -> String? {
let whereClause = PluginSQLFilter.buildWhereClause(
filters: filters,
logicMode: logicMode,
columnKinds: columnKinds,
quoteIdentifier: mssqlQuoteIdentifier,
escapeValue: mssqlEscapeValue,
escapeTypedValue: mssqlEscapeValue,
regexCondition: { quoted, value in
"\(quoted) LIKE '%\(value.replacingOccurrences(of: "'", with: "''"))%'"
}
Expand All @@ -740,13 +758,14 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
quoteIdentifier(identifier)
}

private func mssqlEscapeValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespaces)
if trimmed.caseInsensitiveCompare("NULL") == .orderedSame { return "NULL" }
if trimmed.caseInsensitiveCompare("TRUE") == .orderedSame { return "1" }
if trimmed.caseInsensitiveCompare("FALSE") == .orderedSame { return "0" }
if Int(trimmed) != nil || Double(trimmed) != nil { return trimmed }
return "'\(trimmed.replacingOccurrences(of: "'", with: "''"))'"
private func mssqlEscapeValue(_ value: String, kind: PluginColumnKind?) -> String {
PluginSQLLiteral.escapedLiteral(
value,
kind: kind,
trueLiteral: "1",
falseLiteral: "0",
quote: { "'\($0.replacingOccurrences(of: "'", with: "''"))'" }
)
}


Expand Down
33 changes: 27 additions & 6 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1217,13 +1217,31 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
columns: [String],
limit: Int,
offset: Int
) -> String? {
buildFilteredQuery(
table: table, schema: schema, filters: filters, logicMode: logicMode,
sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:]
)
}

func buildFilteredQuery(
table: String,
schema: String?,
filters: [(column: String, op: String, value: String)],
logicMode: String,
sortColumns: [(columnIndex: Int, ascending: Bool)],
columns: [String],
limit: Int,
offset: Int,
columnKinds: [String: PluginColumnKind]
) -> String? {
var query = "SELECT * FROM \(oracleQualifiedName(schema: schema, table: table))"
let whereClause = PluginSQLFilter.buildWhereClause(
filters: filters,
logicMode: logicMode,
columnKinds: columnKinds,
quoteIdentifier: oracleQuoteIdentifier,
escapeValue: oracleEscapeValue,
escapeTypedValue: oracleEscapeValue,
regexCondition: { quoted, value in
"REGEXP_LIKE(\(quoted), '\(value.replacingOccurrences(of: "'", with: "''"))')"
}
Expand Down Expand Up @@ -1251,11 +1269,14 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
"\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
}

private func oracleEscapeValue(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespaces)
if trimmed.caseInsensitiveCompare("NULL") == .orderedSame { return "NULL" }
if Int(trimmed) != nil || Double(trimmed) != nil { return trimmed }
return "'\(trimmed.replacingOccurrences(of: "'", with: "''"))'"
private func oracleEscapeValue(_ value: String, kind: PluginColumnKind?) -> String {
PluginSQLLiteral.escapedLiteral(
value,
kind: kind,
trueLiteral: nil,
falseLiteral: nil,
quote: { "'\($0.replacingOccurrences(of: "'", with: "''"))'" }
)
}


Expand Down
20 changes: 19 additions & 1 deletion Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,23 @@ final class SurrealDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
columns: [String],
limit: Int,
offset: Int
) -> String? {
buildFilteredQuery(
table: table, schema: schema, filters: filters, logicMode: logicMode,
sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:]
)
}

func buildFilteredQuery(
table: String,
schema: String?,
filters: [(column: String, op: String, value: String)],
logicMode: String,
sortColumns: [(columnIndex: Int, ascending: Bool)],
columns: [String],
limit: Int,
offset: Int,
columnKinds: [String: PluginColumnKind]
) -> String? {
SurrealQueryBuilder.filtered(
table: table,
Expand All @@ -251,7 +268,8 @@ final class SurrealDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
logicMode: logicMode,
sortColumns: Self.sorts(sortColumns, columns: columns),
limit: limit,
offset: offset
offset: offset,
columnKinds: columnKinds
)
}

Expand Down
53 changes: 42 additions & 11 deletions Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import TableProPluginKit

public struct SurrealScope: Equatable, Sendable {
public let namespace: String?
Expand Down Expand Up @@ -45,9 +46,10 @@ public enum SurrealQueryBuilder {
logicMode: String,
sortColumns: [(column: String, ascending: Bool)],
limit: Int,
offset: Int
offset: Int,
columnKinds: [String: PluginColumnKind] = [:]
) -> String {
let clause = whereClause(filters: filters, logicMode: logicMode)
let clause = whereClause(filters: filters, logicMode: logicMode, columnKinds: columnKinds)
return compose(
scope: scope,
statement: select(table: table, where: clause, sortColumns: sortColumns, limit: limit, offset: offset)
Expand Down Expand Up @@ -111,15 +113,19 @@ public enum SurrealQueryBuilder {

public static func whereClause(
filters: [(column: String, op: String, value: String)],
logicMode: String
logicMode: String,
columnKinds: [String: PluginColumnKind] = [:]
) -> String? {
let conditions = filters.compactMap(condition)
let conditions = filters.compactMap { condition($0, kind: columnKinds[$0.column]) }
guard !conditions.isEmpty else { return nil }
let separator = logicMode.lowercased() == "or" ? " OR " : " AND "
return conditions.joined(separator: separator)
}

private static func condition(_ filter: (column: String, op: String, value: String)) -> String? {
private static func condition(
_ filter: (column: String, op: String, value: String),
kind: PluginColumnKind?
) -> String? {
guard !filter.column.isEmpty else { return nil }
let column = SurrealQL.quoteIdentifier(filter.column)
let op = filter.op.uppercased().trimmingCharacters(in: .whitespaces)
Expand All @@ -139,22 +145,22 @@ public enum SurrealQueryBuilder {
case "ENDS WITH":
return "string::ends_with(<string> \(column), \(SurrealQL.stringLiteral(value)))"
case "IN":
return "\(column) INSIDE \(listLiteral(value))"
return "\(column) INSIDE \(listLiteral(value, kind: kind))"
case "NOT IN":
return "\(column) NOTINSIDE \(listLiteral(value))"
return "\(column) NOTINSIDE \(listLiteral(value, kind: kind))"
case "=", "!=", ">", ">=", "<", "<=":
return "\(column) \(op) \(literal(value))"
return "\(column) \(op) \(literal(value, kind: kind))"
case "LIKE":
return "string::contains(<string> \(column), \(SurrealQL.stringLiteral(unwrapWildcards(value))))"
default:
return "\(column) = \(literal(value))"
return "\(column) = \(literal(value, kind: kind))"
}
}

private static func listLiteral(_ value: String) -> String {
private static func listLiteral(_ value: String, kind: PluginColumnKind?) -> String {
let items = value
.split(separator: ",")
.map { literal($0.trimmingCharacters(in: .whitespaces)) }
.map { literal($0.trimmingCharacters(in: .whitespaces), kind: kind) }
return "[" + items.joined(separator: ", ") + "]"
}

Expand All @@ -169,6 +175,31 @@ public enum SurrealQueryBuilder {
return text
}

public static func literal(_ value: String, kind: PluginColumnKind?) -> String {
guard let kind else { return literal(value) }
let trimmed = value.trimmingCharacters(in: .whitespaces)

if !PluginSQLLiteral.isKnownTextLike(kind) {
let lowered = trimmed.lowercased()
if lowered == "null" {
return "NULL"
}
if lowered == "none" {
return "NONE"
}
if lowered == "true" || lowered == "false" {
return lowered
}
if PluginSQLLiteral.isNumericLiteral(trimmed, kind: kind) {
return trimmed
}
}
if let record = recordLiteral(trimmed) {
return record
}
return SurrealQL.stringLiteral(value)
}

public static func literal(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespaces)
let lowered = trimmed.lowercased()
Expand Down
Loading
Loading