diff --git a/Package.swift b/Package.swift index caa3480..6915221 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( ), .package( url: "https://github.com/PureSwift/CoreModel", - from: "2.8.0" + from: "2.11.0" ) ], targets: [ diff --git a/Sources/MongoDBModel/AttributeValue.swift b/Sources/MongoDBModel/AttributeValue.swift index 30cc794..e321ec6 100644 --- a/Sources/MongoDBModel/AttributeValue.swift +++ b/Sources/MongoDBModel/AttributeValue.swift @@ -62,6 +62,11 @@ internal extension AttributeValue { return nil } self = .decimal(decimal) + case (.composite(let elements), .document(let document)): + guard let value = AttributeValue.composite(from: document, elements: elements) else { + return nil + } + self = value case (_, .null): self = .null default: @@ -86,6 +91,9 @@ public extension BSON { self = .binary(try .init(data: data, subtype: .generic)) case .date(let date): self = .datetime(date) + case .composite(let elements): + // a composite maps onto MongoDB's native embedded document + self = .document(try BSONDocument(compositeValue: elements)) case .bool(let value): self = .bool(value) case .int16(let value): diff --git a/Sources/MongoDBModel/CompositeAttribute.swift b/Sources/MongoDBModel/CompositeAttribute.swift new file mode 100644 index 0000000..c3d17da --- /dev/null +++ b/Sources/MongoDBModel/CompositeAttribute.swift @@ -0,0 +1,88 @@ +// +// CompositeAttribute.swift +// CoreModel-MongoDB +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import CoreModel +import MongoSwift + +// MARK: - BSON Conversion + +public extension BSONDocument { + + /// The BSON subdocument for a composite attribute value. + /// + /// A composite maps directly onto an embedded document, which is MongoDB's native + /// shape for structured values — no flattening or serialization is needed, and + /// element key paths (`location.latitude`) are the dotted paths the server already + /// understands for queries, projections and sorts. + init(compositeValue: [PropertyKey: AttributeValue]) throws { + self.init() + for (key, value) in compositeValue { + self[key.rawValue] = try BSON(attributeValue: value) + } + } +} + +internal extension AttributeValue { + + /// Decode a composite attribute value from an embedded BSON document. + /// + /// Iterates the declared elements rather than the stored document, so a stale or + /// unknown field is ignored rather than mis-typed, and an element missing from the + /// document decodes as `.null` so the shape always matches the schema. + static func composite( + from document: BSONDocument, + elements: [Attribute] + ) -> AttributeValue? { + var values = [PropertyKey: AttributeValue](minimumCapacity: elements.count) + for element in elements { + guard let bson = document[element.id.rawValue] else { + values[element.id] = .null + continue + } + guard let value = AttributeValue(bson: bson, type: element.type) else { + return nil + } + values[element.id] = value + } + return .composite(values) + } +} + +// MARK: - Key Path Resolution + +internal extension ModelData { + + /// The attribute value a key path addresses, descending through composite elements. + /// + /// Used by the in-memory evaluation fallback for predicates the server can't run. + /// A property whose name literally contains a dot resolves directly, so models that + /// predate composite attributes are unaffected. + func attributeValue(forKeyPath keyPath: PredicateKeyPath) -> AttributeValue? { + // - Note: The emptiness check comes first: `PropertyKey` asserts on an empty raw + // value, and the root variable of a `Foundation.Predicate` converts to an empty + // key path. + guard case let .property(name)? = keyPath.keys.first else { + return nil + } + if let value = attributes[PropertyKey(rawValue: keyPath.rawValue)] { + return value + } + guard var current = attributes[PropertyKey(rawValue: name)] else { + return nil + } + for key in keyPath.keys.dropFirst() { + guard case let .property(name) = key, + case let .composite(elements) = current, + let next = elements[PropertyKey(rawValue: name)] else { + return nil + } + current = next + } + return current + } +} diff --git a/Sources/MongoDBModel/FunctionEvaluation.swift b/Sources/MongoDBModel/FunctionEvaluation.swift index 407018f..2845fce 100644 --- a/Sources/MongoDBModel/FunctionEvaluation.swift +++ b/Sources/MongoDBModel/FunctionEvaluation.swift @@ -68,6 +68,9 @@ internal extension FetchRequest.Predicate.Expression { switch self { case .function: return true + case let .arithmetic(expression): + // an operand may itself be a function call + return expression.left.containsFunction || expression.right.containsFunction case .attribute, .relationship, .keyPath: return false } @@ -125,7 +128,8 @@ internal extension FetchRequest.Predicate.Expression { case let .attribute(value): return value case let .keyPath(keyPath): - return data.attributes[PropertyKey(rawValue: keyPath.rawValue)] + // descends into composite attribute elements, e.g. `location.latitude` + return data.attributeValue(forKeyPath: keyPath) case let .function(function): guard let registered = functions[function.name] else { return nil @@ -135,6 +139,9 @@ internal extension FetchRequest.Predicate.Expression { case .relationship: // relationships aren't compared by the in-memory function path return nil + case .arithmetic: + // - TODO: Evaluate arithmetic expressions in the in-memory fallback. + return nil } } } diff --git a/Sources/MongoDBModel/Predicate.swift b/Sources/MongoDBModel/Predicate.swift index eac3563..e7ab51c 100644 --- a/Sources/MongoDBModel/Predicate.swift +++ b/Sources/MongoDBModel/Predicate.swift @@ -56,8 +56,8 @@ public extension BSONDocument { } let valueBSON: BSON switch predicate.right { - case .keyPath, .function: - // custom functions cannot be executed by the server + case .keyPath, .function, .arithmetic: + // custom functions and arithmetic expressions cannot be executed by the server return nil case let .attribute(value): guard let bson = try? BSON(attributeValue: value) else { diff --git a/Tests/CoreModelMongoDBTests/CompositeAttributeTests.swift b/Tests/CoreModelMongoDBTests/CompositeAttributeTests.swift new file mode 100644 index 0000000..b5bb954 --- /dev/null +++ b/Tests/CoreModelMongoDBTests/CompositeAttributeTests.swift @@ -0,0 +1,147 @@ +// +// CompositeAttributeTests.swift +// CoreModel-MongoDB +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import XCTest +@testable import CoreModel +@testable import MongoDBModel +import MongoSwift + +/// Composite attribute conversion, which needs no running server. +final class CompositeAttributeTests: XCTestCase { + + static let locationElements: [Attribute] = [ + Attribute(id: "latitude", type: .double), + Attribute(id: "longitude", type: .double) + ] + + static let addressElements: [Attribute] = [ + Attribute(id: "street", type: .string), + Attribute(id: "location", type: .composite(locationElements)) + ] + + static var location: AttributeValue { + .composite(["latitude": .double(40.7), "longitude": .double(-74.0)]) + } + + static var address: AttributeValue { + .composite(["street": .string("1 Main"), "location": location]) + } + + /// A composite becomes an embedded document, not a string or binary blob. + func testEncodeComposite() throws { + let bson = try BSON(attributeValue: Self.location) + guard case let .document(document) = bson else { + return XCTFail("expected an embedded document, got \(bson)") + } + XCTAssertEqual(document["latitude"], .double(40.7)) + XCTAssertEqual(document["longitude"], .double(-74.0)) + XCTAssertEqual(document.keys.sorted(), ["latitude", "longitude"]) + } + + func testEncodeNestedComposite() throws { + let bson = try BSON(attributeValue: Self.address) + guard case let .document(document) = bson, + case let .document(inner)? = document["location"] else { + return XCTFail("expected a nested embedded document, got \(bson)") + } + XCTAssertEqual(document["street"], .string("1 Main")) + XCTAssertEqual(inner["latitude"], .double(40.7)) + } + + func testDecodeComposite() throws { + let bson = try BSON(attributeValue: Self.location) + let decoded = AttributeValue(bson: bson, type: .composite(Self.locationElements)) + XCTAssertEqual(decoded, Self.location) + } + + func testDecodeNestedComposite() throws { + let bson = try BSON(attributeValue: Self.address) + let decoded = AttributeValue(bson: bson, type: .composite(Self.addressElements)) + XCTAssertEqual(decoded, Self.address) + } + + /// An element absent from the stored document decodes as null, so the decoded shape + /// always matches the schema. + func testDecodeMissingElement() throws { + let document: BSONDocument = ["latitude": .double(40.7)] + let decoded = AttributeValue(bson: .document(document), type: .composite(Self.locationElements)) + XCTAssertEqual(decoded, .composite(["latitude": .double(40.7), "longitude": .null])) + } + + /// A stale field the schema no longer declares is ignored rather than mis-typed. + func testDecodeIgnoresUnknownField() throws { + let document: BSONDocument = [ + "latitude": .double(40.7), + "longitude": .double(-74.0), + "altitude": .double(3.0) + ] + let decoded = AttributeValue(bson: .document(document), type: .composite(Self.locationElements)) + XCTAssertEqual(decoded, Self.location) + } + + func testDecodeWrongElementType() throws { + let document: BSONDocument = ["latitude": .string("nope"), "longitude": .double(-74.0)] + XCTAssertNil(AttributeValue(bson: .document(document), type: .composite(Self.locationElements))) + } + + func testDecodeNull() throws { + XCTAssertEqual(AttributeValue(bson: .null, type: .composite(Self.locationElements)), .null) + } + + // MARK: - Key path resolution + + func testKeyPathResolution() { + let data = ModelData( + entity: "Facility", + id: "north", + attributes: ["name": .string("North"), "location": Self.location, "address": Self.address] + ) + XCTAssertEqual(data.attributeValue(forKeyPath: "name"), .string("North")) + XCTAssertEqual(data.attributeValue(forKeyPath: "location"), Self.location) + XCTAssertEqual(data.attributeValue(forKeyPath: "location.latitude"), .double(40.7)) + XCTAssertEqual(data.attributeValue(forKeyPath: "address.location.longitude"), .double(-74.0)) + XCTAssertNil(data.attributeValue(forKeyPath: "location.altitude")) + XCTAssertNil(data.attributeValue(forKeyPath: "name.length")) + XCTAssertNil(data.attributeValue(forKeyPath: PredicateKeyPath(keys: []))) + } + + /// A property whose name literally contains a dot still resolves directly. + func testLiteralDottedNameWins() { + let data = ModelData( + entity: "Facility", + id: "north", + attributes: [ + "location.latitude": .double(1), + "location": .composite(["latitude": .double(2)]) + ] + ) + XCTAssertEqual(data.attributeValue(forKeyPath: "location.latitude"), .double(1)) + } + + /// The server query for an element key path uses MongoDB's native dotted field path. + func testElementPredicateQuery() throws { + let comparison = FetchRequest.Predicate.Comparison( + left: .keyPath("location.latitude"), + right: .attribute(.double(30)), + type: .greaterThan + ) + let document = try XCTUnwrap(BSONDocument(predicate: comparison)) + XCTAssertEqual(document.keys, ["location.latitude"]) + XCTAssertEqual(document["location.latitude"], .document(["$gt": .double(30)])) + } + + func testNestedElementPredicateQuery() throws { + let comparison = FetchRequest.Predicate.Comparison( + left: .keyPath("address.location.latitude"), + right: .attribute(.double(30)), + type: .lessThan + ) + let document = try XCTUnwrap(BSONDocument(predicate: comparison)) + XCTAssertEqual(document.keys, ["address.location.latitude"]) + } +}