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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- The tree sidebar can show only the databases you pick. Use the filter button to check the ones you want, with a search box for long lists. The choice is saved per connection. (#1667)

### Fixed

- MongoDB filters on `_id` and other ObjectId fields now match. A 24-character hex value is matched as an ObjectId as well as a string, so filtering by `_id` returns the row instead of nothing. (#1682)

## [0.51.0] - 2026-06-13

### Added
Expand Down
24 changes: 22 additions & 2 deletions Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,14 @@ struct MongoDBQueryBuilder {

switch op {
case "=":
if let oid = objectIdJson(value) {
return "\"$or\": [{\"\(field)\": \(oid)}, {\"\(field)\": \(jsonValue(value))}]"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quote the string arm for ObjectId filters

When the filtered ObjectId is a valid 24-character hex value made only of digits with leading zeroes, such as 000000000000000000000001, jsonValue(value) treats it as an Int64 and emits the raw token with leading zeroes. That makes the generated filter invalid JSON, so the MongoDB driver rejects the query instead of matching the ObjectId; the fallback string branch here should be forced to an escaped string literal rather than going through numeric auto-detection.

Useful? React with 👍 / 👎.

}
return "\"\(field)\": \(jsonValue(value))"
case "!=":
if let oid = objectIdJson(value) {
return "\"\(field)\": {\"$nin\": [\(oid), \(jsonValue(value))]}"
}
return "\"\(field)\": {\"$ne\": \(jsonValue(value))}"
case ">":
return "\"\(field)\": {\"$gt\": \(jsonValue(value))}"
Expand Down Expand Up @@ -137,11 +143,11 @@ struct MongoDBQueryBuilder {
return "\"\(field)\": \(Self.regexBody(pattern: value))"
case "IN":
let items = value.split(separator: ",")
.map { jsonValue(String($0).trimmingCharacters(in: .whitespaces)) }
.flatMap { inValues(String($0).trimmingCharacters(in: .whitespaces)) }
return "\"\(field)\": {\"$in\": [\(items.joined(separator: ", "))]}"
case "NOT IN":
let items = value.split(separator: ",")
.map { jsonValue(String($0).trimmingCharacters(in: .whitespaces)) }
.flatMap { inValues(String($0).trimmingCharacters(in: .whitespaces)) }
return "\"\(field)\": {\"$nin\": [\(items.joined(separator: ", "))]}"
case "BETWEEN":
let parts = value.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
Expand Down Expand Up @@ -178,6 +184,20 @@ struct MongoDBQueryBuilder {
return "\"\(Self.escapeJsonString(value))\""
}

private func inValues(_ value: String) -> [String] {
if let oid = objectIdJson(value) {
return [oid, jsonValue(value)]
}
return [jsonValue(value)]
}

private func objectIdJson(_ value: String) -> String? {
guard (value as NSString).length == 24, value.allSatisfy({ $0.isASCII && $0.isHexDigit }) else {
return nil
}
return "{\"$oid\": \"\(value)\"}"
}

private static func regexBody(pattern: String) -> String {
"{\"$regex\": \"\(escapeJsonString(pattern))\", \"$options\": \"i\"}"
}
Expand Down
107 changes: 107 additions & 0 deletions TableProTests/Plugins/MongoDBQueryBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,113 @@ struct MongoDBQueryBuilderTests {
#expect(query.contains(".countDocuments({})"))
}

// MARK: - ObjectId Matching

@Test("Equals on an ObjectId value matches both the ObjectId and the string form")
func equalsObjectIdDualMatch() {
let doc = builder.buildFilterDocument(
from: [(column: "_id", op: "=", value: "66c0fa26dfcb27034e646356")]
)
let parsed = parseFilter(doc)
let branches = parsed?["$or"] as? [[String: Any]]
#expect(branches?.count == 2)
let oid = (branches?.first?["_id"] as? [String: Any])?["$oid"] as? String
#expect(oid == "66c0fa26dfcb27034e646356")
#expect(branches?.last?["_id"] as? String == "66c0fa26dfcb27034e646356")
}

@Test("Equals on a non-ObjectId string stays a plain string match")
func equalsNonObjectIdString() {
let doc = builder.buildFilterDocument(
from: [(column: "_id", op: "=", value: "user-123")]
)
#expect(!doc.contains("$or"))
#expect(!doc.contains("$oid"))
#expect(doc.contains("\"_id\": \"user-123\""))
}

@Test("Equals on a 23-character hex value is not treated as an ObjectId")
func equalsShortHexNotObjectId() {
let doc = builder.buildFilterDocument(
from: [(column: "_id", op: "=", value: "66c0fa26dfcb27034e64635")]
)
#expect(!doc.contains("$oid"))
}

@Test("Equals on a 24-character non-hex value is not treated as an ObjectId")
func equalsNonHexNotObjectId() {
let doc = builder.buildFilterDocument(
from: [(column: "_id", op: "=", value: "zzc0fa26dfcb27034e646356")]
)
#expect(!doc.contains("$oid"))
}

@Test("ObjectId matching applies to non-_id reference fields too")
func equalsObjectIdReferenceField() {
let doc = builder.buildFilterDocument(
from: [(column: "userId", op: "=", value: "66c0fa26dfcb27034e646356")]
)
let branches = parseFilter(doc)?["$or"] as? [[String: Any]]
let oid = (branches?.first?["userId"] as? [String: Any])?["$oid"] as? String
#expect(oid == "66c0fa26dfcb27034e646356")
}

@Test("Not-equals on an ObjectId value excludes both the ObjectId and the string form")
func notEqualsObjectIdDualMatch() {
let doc = builder.buildFilterDocument(
from: [(column: "_id", op: "!=", value: "66c0fa26dfcb27034e646356")]
)
let nin = (parseFilter(doc)?["_id"] as? [String: Any])?["$nin"] as? [Any]
#expect(nin?.count == 2)
let oid = (nin?.first as? [String: Any])?["$oid"] as? String
#expect(oid == "66c0fa26dfcb27034e646356")
#expect(nin?.last as? String == "66c0fa26dfcb27034e646356")
}

@Test("IN expands an ObjectId item to both forms and leaves plain items alone")
func inExpandsObjectIdItems() {
let doc = builder.buildFilterDocument(
from: [(column: "_id", op: "IN", value: "66c0fa26dfcb27034e646356, plain-id")]
)
let inArray = (parseFilter(doc)?["_id"] as? [String: Any])?["$in"] as? [Any]
#expect(inArray?.count == 3)
let oid = (inArray?.first as? [String: Any])?["$oid"] as? String
#expect(oid == "66c0fa26dfcb27034e646356")
let strings = inArray?.compactMap { $0 as? String }
#expect(strings?.contains("66c0fa26dfcb27034e646356") == true)
#expect(strings?.contains("plain-id") == true)
}

@Test("NOT IN expands an ObjectId item to both forms")
func notInExpandsObjectIdItems() {
let doc = builder.buildFilterDocument(
from: [(column: "_id", op: "NOT IN", value: "66c0fa26dfcb27034e646356, plain-id")]
)
let ninArray = (parseFilter(doc)?["_id"] as? [String: Any])?["$nin"] as? [Any]
#expect(ninArray?.count == 3)
let oid = (ninArray?.first as? [String: Any])?["$oid"] as? String
#expect(oid == "66c0fa26dfcb27034e646356")
let strings = ninArray?.compactMap { $0 as? String }
#expect(strings?.contains("plain-id") == true)
}

@Test("An ObjectId equals combined with another filter stays valid JSON under $and")
func objectIdEqualsCombinedWithAndFilter() {
let doc = builder.buildFilterDocument(
from: [
(column: "_id", op: "=", value: "66c0fa26dfcb27034e646356"),
(column: "shop", op: "=", value: "acme")
],
logicMode: "and"
)
let branches = parseFilter(doc)?["$and"] as? [[String: Any]]
#expect(branches?.count == 2)
let or = branches?.first?["$or"] as? [[String: Any]]
let oid = (or?.first?["_id"] as? [String: Any])?["$oid"] as? String
#expect(oid == "66c0fa26dfcb27034e646356")
#expect(branches?.last?["shop"] as? String == "acme")
}

// MARK: - Security (NoSQL injection)

private func parseFilter(_ json: String) -> [String: Any]? {
Expand Down
Loading