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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Every saved group lost when one unreadable entry stopped the whole list decoding. (#1311)
- Two Macs re-uploading the whole group list to each other after a single group changed. (#1311)
- Deleting a group that a broken sync left in a loop also deleting the group it pointed at. (#1311)
- "Operator does not exist" from a Contains, Starts with, Ends with or Regex filter on a PostgreSQL uuid, enum, number, date or json column.
- "Function lower does not exist" from an ignore-case filter on a PostgreSQL column that is not text.
- Is empty filter on a PostgreSQL array column.
- MongoDB collection named like a `db` method, such as `stats` or `version`, failing to open, save or export.
- Row count missing after a MongoDB raw filter written in shell syntax.

## [0.71.0] - 2026-09-02

Expand Down
15 changes: 1 addition & 14 deletions Plugins/MQLExportPlugin/MQLExportHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,8 @@ import TableProNumberFormatting
import TableProPluginKit

enum MQLExportHelpers {
static func escapeJSIdentifier(_ name: String) -> String {
guard let firstChar = name.first,
!firstChar.isNumber,
name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else {
return "[\"\(PluginExportUtilities.escapeJSONString(name))\"]"
}
return name
}

static func collectionAccessor(for name: String) -> String {
let escaped = escapeJSIdentifier(name)
if escaped.hasPrefix("[") {
return "db\(escaped)"
}
return "db.\(escaped)"
MongoCollectionAccessor.expression(for: name)
}

static func mqlBinaryValue(for data: Data, subtype: UInt8) -> String {
Expand Down
18 changes: 13 additions & 5 deletions Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
private var scriptRuntime: MongoScriptRuntime?
private var currentDb: String
private let columnKindLock = NSLock()
private let rawFilterNormalizer = MongoDBRawFilterNormalizer()
private var columnKindsByCollection: [String: [String: BsonValueKind]] = [:]
private var fieldPathKindsByCollection: [String: [String: BsonValueKind]] = [:]

Expand Down Expand Up @@ -432,7 +433,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
throw MongoDBPluginError.notConnected
}

let filterJson = MongoDBQueryBuilder(columnKinds: filterKinds(for: table))
let filterJson = filterQueryBuilder(for: table)
.buildFilterDocument(from: filters, logicMode: logicMode)
let count = try await conn.countDocuments(
database: currentDb, collection: table, filter: filterJson, background: background
Expand Down Expand Up @@ -494,9 +495,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
opts.append("\"name\": \"\(name)\"")

let optsJson = "{\(opts.joined(separator: ", "))}"
let escapedTable = table.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
sections.append("db[\"\(escapedTable)\"].createIndex(\(keyJson), \(optsJson))")
let accessor = MongoCollectionAccessor.expression(for: table)
sections.append("\(accessor).createIndex(\(keyJson), \(optsJson))")
}
}
} catch {
Expand Down Expand Up @@ -699,7 +699,7 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
offset: Int,
columnKinds: [String: PluginColumnKind]
) -> String? {
let builder = MongoDBQueryBuilder(columnKinds: filterKinds(for: table))
let builder = filterQueryBuilder(for: table)
return builder.buildFilteredQuery(
collection: table, queryFilters: queryFilters, logicMode: logicMode,
sortColumns: sortColumns, columns: columns, limit: limit, offset: offset
Expand Down Expand Up @@ -923,6 +923,14 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}

private func filterQueryBuilder(for collection: String) -> MongoDBQueryBuilder {
let normalizer = rawFilterNormalizer
return MongoDBQueryBuilder(
columnKinds: filterKinds(for: collection),
rawFilterNormalizer: { normalizer.normalize($0) }
)
}

/// Two databases can hold a collection of the same name with different field types.
private func columnKindKey(_ collection: String) -> String {
"\(currentDb)\u{0}\(collection)"
Expand Down
20 changes: 12 additions & 8 deletions Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ struct MongoDBQueryBuilder {

let columnKinds: [String: BsonValueKind]

init(columnKinds: [String: BsonValueKind] = [:]) {
/// Rewrites a raw filter row into canonical Extended JSON, so `find` and `countDocuments`
/// receive the same document. Without one the row's text is used as typed.
let rawFilterNormalizer: (@Sendable (String) -> String?)?

init(
columnKinds: [String: BsonValueKind] = [:],
rawFilterNormalizer: (@Sendable (String) -> String?)? = nil
) {
self.columnKinds = columnKinds
self.rawFilterNormalizer = rawFilterNormalizer
}

// MARK: - Base Query
Expand Down Expand Up @@ -155,7 +163,8 @@ struct MongoDBQueryBuilder {
guard filter.column == Self.rawFilterColumn else { return nil }
let trimmed = filter.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("{"), trimmed.hasSuffix("}") else { return nil }
return MongoDBFilterClause(key: "$and", body: "[\(trimmed)]")
let document = rawFilterNormalizer?(trimmed) ?? trimmed
return MongoDBFilterClause(key: "$and", body: "[\(document)]")
}

/// One `$elemMatch` per array prefix. Every condition is re-keyed to its path relative to the
Expand Down Expand Up @@ -194,12 +203,7 @@ struct MongoDBQueryBuilder {
}

private static func mongoCollectionAccessor(_ name: String) -> String {
guard let firstChar = name.first,
!firstChar.isNumber,
name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else {
return "db[\"\(escapeJsonString(name))\"]"
}
return "db.\(name)"
MongoCollectionAccessor.expression(for: name)
}

private func buildClause(for filter: PluginQueryFilter, field rawField: String) -> MongoDBFilterClause? {
Expand Down
97 changes: 97 additions & 0 deletions Plugins/MongoDBDriverPlugin/MongoDBRawFilterNormalizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// MongoDBRawFilterNormalizer.swift
// MongoDBDriverPlugin
//

import Foundation
import JavaScriptCore

/// Turns a filter document the user typed in shell syntax into canonical Extended JSON.
///
/// A raw filter row reaches two consumers with two parsers: `find` evaluates it as JavaScript,
/// where `{status: "active", _id: ObjectId("…")}` is fine, and `countDocuments` hands the same
/// text to libmongoc's JSON parser, where it is not. Serializing through the shell's own `EJSON`
/// once, up front, gives both the document the user meant.
///
/// The prelude is the same one the shell runs, with the host stubbed: it answers the one call
/// the prelude makes while loading and refuses every other, so anything that would need the
/// server, such as `ObjectId()` with no argument, comes back as `nil` and the caller keeps the
/// text it had.
///
/// The text is JavaScript, so it can also loop forever, and JavaScriptCore's public API cannot
/// interrupt it. Like `MongoScriptRuntime`, each engine runs on a queue of its own: a document
/// that misses the deadline is answered `nil`, its engine is abandoned with the queue it wedged,
/// and the next call builds a fresh one.
final class MongoDBRawFilterNormalizer: @unchecked Sendable {
private static let deadline: TimeInterval = 2

private final class Engine: @unchecked Sendable {
let queue: DispatchQueue
let context: JSContext

init(queue: DispatchQueue, context: JSContext) {
self.queue = queue
self.context = context
}
}

private final class Outcome: @unchecked Sendable {
var value: String?
}

private let lock = NSLock()
private var engine: Engine?
private var generation = 0

func normalize(_ document: String) -> String? {
lock.lock()
defer { lock.unlock() }
guard let engine = preparedEngine() else { return nil }

let outcome = Outcome()
let finished = DispatchSemaphore(value: 0)
engine.queue.async {
outcome.value = Self.serialize(document, in: engine.context)
finished.signal()
}
guard finished.wait(timeout: .now() + Self.deadline) == .success else {
self.engine = nil
return nil
}
return outcome.value
}

private static func serialize(_ document: String, in context: JSContext) -> String? {
context.exception = nil
let value = context.evaluateScript("__ejson((\(document)))")
guard context.exception == nil, let value, value.isString else { return nil }
return value.toString()
}

private func preparedEngine() -> Engine? {
if let engine { return engine }
guard let context = JSContext(virtualMachine: JSVirtualMachine()) else { return nil }
let host: @convention(block) (String) -> String = { request in Self.answer(request) }
let swallowOutput: @convention(block) (String) -> Bool = { _ in true }
context.setObject(host, forKeyedSubscript: "__tp_exec" as NSString)
context.setObject(swallowOutput, forKeyedSubscript: "__tp_print" as NSString)
context.evaluateScript(MongoScriptPrelude.source)
guard context.exception == nil else { return nil }

generation += 1
let queue = DispatchQueue(
label: "com.TablePro.mongodb.rawfilter.\(generation)", qos: .userInitiated
)
let built = Engine(queue: queue, context: context)
engine = built
return built
}

private static func answer(_ request: String) -> String {
let parsed = try? JSONSerialization.jsonObject(with: Data(request.utf8)) as? [String: Any]
guard parsed?["op"] as? String == "currentDatabase" else {
return MongoScriptJson.failure(message: "The server is not reachable from a filter", code: 0)
}
return MongoScriptJson.success(MongoScriptJson.jsonString(""))
}
}
3 changes: 1 addition & 2 deletions Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ struct MongoDBStatementGenerator {
let columns: [String]
var columnKinds: [String: BsonValueKind] = [:]

/// Collection accessor using bracket notation for safety with dotted names
private var collectionAccessor: String {
"db[\"\(escapeJsonString(collectionName))\"]"
MongoCollectionAccessor.expression(for: collectionName)
}

/// Index of "_id" field in the columns array (used as primary key equivalent)
Expand Down
3 changes: 2 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ enum PostgreSQLDialect {
likeEscapeStyle: .explicit,
paginationStyle: .limit,
caseSensitivityStyle: .ilikeOperator,
operators: operators
operators: operators,
textCastTypeName: "TEXT"
)

static let keywords: Set<String> = reservedKeywords
Expand Down
55 changes: 55 additions & 0 deletions Plugins/TableProPluginKit/MongoCollectionAccessor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// MongoCollectionAccessor.swift
// TableProPluginKit
//

import Foundation

/// Spells the shell expression that reaches a collection by name.
///
/// `db.<name>` and `db["<name>"]` both go through the `db` object's property lookup, and in
/// mongosh as in TablePro's own shell that lookup answers a method before a collection. So a
/// collection called `stats` or `version` comes back as a function, and `.find()` on it is a
/// TypeError. `db.getCollection("<name>")` is the one spelling that cannot be shadowed.
public enum MongoCollectionAccessor {
public static func expression(for name: String) -> String {
guard isPlainIdentifier(name), !isShadowedByDatabaseMember(name) else {
return "db.getCollection(\"\(PluginExportUtilities.escapeJSONString(name))\")"
}
return "db.\(name)"
}

public static func unescape(_ escaped: String) -> String {
let quoted = Data("\"\(escaped)\"".utf8)
return (try? JSONDecoder().decode(String.self, from: quoted)) ?? escaped
}

public static func isShadowedByDatabaseMember(_ name: String) -> Bool {
name.hasPrefix("__") || databaseMemberNames.contains(name)
}

private static func isPlainIdentifier(_ name: String) -> Bool {
guard let first = name.first, !first.isNumber else { return false }
return name.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" }
}

/// Every method mongosh puts on `db`, plus what `Object.prototype` gives any JavaScript value.
public static let databaseMemberNames: Set<String> = [
"adminCommand", "aggregate", "auth", "changeUserPassword", "checkMetadataConsistency",
"commandHelp", "createCollection", "createRole", "createUser", "createView", "currentOp",
"disableFreeMonitoring", "dropAllRoles", "dropAllUsers", "dropDatabase", "dropRole",
"dropUser", "enableFreeMonitoring", "fsyncLock", "fsyncUnlock", "getCollection",
"getCollectionInfos", "getCollectionNames", "getFreeMonitoringStatus", "getLastError",
"getLastErrorObj", "getLogComponents", "getMongo", "getName", "getProfilingLevel",
"getProfilingStatus", "getReplicationInfo", "getRole", "getRoles", "getSiblingDB",
"getUser", "getUsers", "grantPrivilegesToRole", "grantRolesToRole", "grantRolesToUser",
"hello", "help", "hostInfo", "isMaster", "killOp", "listCommands", "logout",
"printCollectionStats", "printReplicationInfo", "printSecondaryReplicationInfo",
"printShardingStatus", "printSlaveReplicationInfo", "removeUser", "revokePrivilegesFromRole",
"revokeRolesFromRole", "revokeRolesFromUser", "rotateCertificates", "runCommand",
"serverBuildInfo", "serverCmdLineOpts", "serverStatus", "setLogLevel", "setProfilingLevel",
"shutdownServer", "sql", "stats", "updateRole", "updateUser", "version", "watch",
"constructor", "hasOwnProperty", "isPrototypeOf", "propertyIsEnumerable",
"toLocaleString", "toString", "valueOf"
]
}
49 changes: 48 additions & 1 deletion Plugins/TableProPluginKit/SQLDialectDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ public struct SQLDialectDescriptor: Sendable {
public let caseSensitivityStyle: CaseSensitivityStyle
public let caseFoldFunction: String

// Pattern matching on a non-character column
/// The type a column that is not character data is cast to before `LIKE`, a regex or a case
/// fold. `nil` means the engine coerces the operand itself. PostgreSQL does not: `uuid ~~ unknown`
/// and `lower(integer)` are both "operator does not exist".
public let textCastTypeName: String?

// Authoring
public let operators: [SQLOperatorDescriptor]

Expand Down Expand Up @@ -191,6 +197,7 @@ public struct SQLDialectDescriptor: Sendable {
)
}

@_disfavoredOverload
public init(
identifierQuote: String,
keywords: Set<String>,
Expand All @@ -207,6 +214,44 @@ public struct SQLDialectDescriptor: Sendable {
caseSensitivityStyle: CaseSensitivityStyle = .unsupported,
caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction,
operators: [SQLOperatorDescriptor] = []
) {
self.init(
identifierQuote: identifierQuote,
keywords: keywords,
functions: functions,
dataTypes: dataTypes,
tableOptions: tableOptions,
regexSyntax: regexSyntax,
booleanLiteralStyle: booleanLiteralStyle,
likeEscapeStyle: likeEscapeStyle,
paginationStyle: paginationStyle,
offsetFetchOrderBy: offsetFetchOrderBy,
requiresBackslashEscaping: requiresBackslashEscaping,
autoLimitStyle: autoLimitStyle,
caseSensitivityStyle: caseSensitivityStyle,
caseFoldFunction: caseFoldFunction,
operators: operators,
textCastTypeName: nil
)
}

public init(
identifierQuote: String,
keywords: Set<String>,
functions: Set<String>,
dataTypes: Set<String>,
tableOptions: [String] = [],
regexSyntax: RegexSyntax = .unsupported,
booleanLiteralStyle: BooleanLiteralStyle = .numeric,
likeEscapeStyle: LikeEscapeStyle = .explicit,
paginationStyle: PaginationStyle = .limit,
offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)",
requiresBackslashEscaping: Bool = false,
autoLimitStyle: AutoLimitStyle = .limit,
caseSensitivityStyle: CaseSensitivityStyle = .unsupported,
caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction,
operators: [SQLOperatorDescriptor] = [],
textCastTypeName: String?
) {
self.identifierQuote = identifierQuote
self.keywords = keywords
Expand All @@ -223,6 +268,7 @@ public struct SQLDialectDescriptor: Sendable {
self.caseSensitivityStyle = caseSensitivityStyle
self.caseFoldFunction = caseFoldFunction
self.operators = operators
self.textCastTypeName = textCastTypeName
}

public static let defaultCaseFoldFunction = "LOWER"
Expand All @@ -246,7 +292,8 @@ public struct SQLDialectDescriptor: Sendable {
autoLimitStyle: autoLimitStyle,
caseSensitivityStyle: style,
caseFoldFunction: caseFoldFunction,
operators: operators
operators: operators,
textCastTypeName: textCastTypeName
)
}
}
Loading
Loading