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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ let package = Package(
),
.package(
url: "https://github.com/PureSwift/CoreModel",
from: "2.8.0"
from: "2.11.0"
)
],
targets: [
Expand Down
8 changes: 8 additions & 0 deletions Sources/MongoDBModel/AttributeValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down
88 changes: 88 additions & 0 deletions Sources/MongoDBModel/CompositeAttribute.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 8 additions & 1 deletion Sources/MongoDBModel/FunctionEvaluation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/MongoDBModel/Predicate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
147 changes: 147 additions & 0 deletions Tests/CoreModelMongoDBTests/CompositeAttributeTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Loading