diff --git a/CHANGELOG.md b/CHANGELOG.md index 39fb14398..5cfc84479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift index b3ed845ed..5bdfe4cc5 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBQueryBuilder.swift @@ -106,8 +106,14 @@ struct MongoDBQueryBuilder { switch op { case "=": + if let oid = objectIdJson(value) { + return "\"$or\": [{\"\(field)\": \(oid)}, {\"\(field)\": \(jsonValue(value))}]" + } 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))}" @@ -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) } @@ -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\"}" } diff --git a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift index 91a2179d7..4235af55a 100644 --- a/TableProTests/Plugins/MongoDBQueryBuilderTests.swift +++ b/TableProTests/Plugins/MongoDBQueryBuilderTests.swift @@ -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]? {