Skip to content

Commit 3612dee

Browse files
committed
#11 Add nested composite attribute store tests
1 parent 97718e9 commit 3612dee

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
//
2+
// CompositeNestedTests.swift
3+
// CoreModel
4+
//
5+
// Created by Alsey Coleman Miller on 8/16/26.
6+
//
7+
8+
import Foundation
9+
import Testing
10+
@testable import CoreModel
11+
#if canImport(CoreData)
12+
import CoreData
13+
@testable import CoreDataModel
14+
#endif
15+
16+
/// Nested composite attributes — a composite whose element is itself a composite —
17+
/// exercised identically against the in-memory store and CoreData.
18+
///
19+
/// Every assertion lives in a `Self.assert…` helper taking `some ModelStorage`, so the
20+
/// two backends are held to the same behavior rather than to two hand-written copies.
21+
@Suite struct CompositeNestedTests {
22+
23+
static let model = Model(entities: [
24+
EntityDescription(entity: Facility.self)
25+
])
26+
27+
static func facility(
28+
name: String,
29+
street: String,
30+
city: String? = "Springfield",
31+
latitude: Double,
32+
longitude: Double,
33+
billing: Address? = nil
34+
) -> Facility {
35+
Facility(
36+
name: name,
37+
address: Address(
38+
street: street,
39+
city: city,
40+
location: Campground.LocationCoordinates(latitude: latitude, longitude: longitude)
41+
),
42+
billingAddress: billing
43+
)
44+
}
45+
46+
// MARK: - Shared assertions
47+
48+
/// A nested composite survives a round trip with every level intact.
49+
static func assertRoundTrip(_ store: some ModelStorage) async throws {
50+
let facility = facility(
51+
name: "North",
52+
street: "1 Main",
53+
latitude: 34.51446212994721,
54+
longitude: -89.15318142250365
55+
)
56+
try await store.insert(facility)
57+
let fetched = try #require(try await store.fetch(Facility.self, for: facility.id))
58+
#expect(fetched == facility)
59+
#expect(fetched.address.street == "1 Main")
60+
#expect(fetched.address.city == "Springfield")
61+
// the innermost level keeps full floating point precision
62+
#expect(fetched.address.location.latitude == 34.51446212994721)
63+
#expect(fetched.address.location.longitude == -89.15318142250365)
64+
}
65+
66+
/// The stored value is nested structure, not a flattened key space.
67+
static func assertStoredValueIsNested(_ store: some ModelStorage) async throws {
68+
let facility = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0)
69+
try await store.insert(facility)
70+
let data = try #require(try await store.fetch(Facility.entityName, for: ObjectID(facility.id)))
71+
#expect(data.attributes["address"] == .composite([
72+
"street": .string("1 Main"),
73+
"city": .string("Springfield"),
74+
"location": .composite([
75+
"latitude": .double(40.7),
76+
"longitude": .double(-74.0)
77+
])
78+
]))
79+
// there is no flattened key for the nested element
80+
#expect(data.attributes["address.location"] == nil)
81+
#expect(data.attributes["address.location.latitude"] == nil)
82+
}
83+
84+
/// A predicate can address an element two levels deep.
85+
static func assertNestedPredicate(_ store: some ModelStorage) async throws {
86+
let north = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0)
87+
let south = facility(name: "South", street: "2 Oak", latitude: 25.8, longitude: -80.2)
88+
try await store.insert([try north.encode(), try south.encode()])
89+
let deep = try await store.fetch(
90+
Facility.self,
91+
predicate: "address.location.latitude" > 30
92+
)
93+
#expect(deep.map(\.name) == ["North"])
94+
// and one level deep, on the same composite
95+
let shallow = try await store.fetch(
96+
Facility.self,
97+
predicate: "address.street" == "2 Oak"
98+
)
99+
#expect(shallow.map(\.name) == ["South"])
100+
}
101+
102+
/// A sort descriptor can address an element two levels deep.
103+
static func assertNestedSort(_ store: some ModelStorage) async throws {
104+
let north = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0)
105+
let south = facility(name: "South", street: "2 Oak", latitude: 25.8, longitude: -80.2)
106+
try await store.insert([try north.encode(), try south.encode()])
107+
let ascending = try await store.fetch(
108+
Facility.self,
109+
sortDescriptors: [.init(property: "address.location.latitude", ascending: true)]
110+
)
111+
#expect(ascending.map(\.name) == ["South", "North"])
112+
let descending = try await store.fetch(
113+
Facility.self,
114+
sortDescriptors: [.init(property: "address.location.latitude", ascending: false)]
115+
)
116+
#expect(descending.map(\.name) == ["North", "South"])
117+
}
118+
119+
/// An optional composite is absent as a whole, while a nested optional *element*
120+
/// is null within a composite that is itself present.
121+
static func assertNullHandling(_ store: some ModelStorage) async throws {
122+
let facility = facility(
123+
name: "North",
124+
street: "1 Main",
125+
city: nil,
126+
latitude: 40.7,
127+
longitude: -74.0
128+
)
129+
try await store.insert(facility)
130+
let fetched = try #require(try await store.fetch(Facility.self, for: facility.id))
131+
#expect(fetched.billingAddress == nil)
132+
#expect(fetched.address.city == nil)
133+
#expect(fetched.address.street == "1 Main")
134+
let data = try #require(try await store.fetch(Facility.entityName, for: ObjectID(facility.id)))
135+
// the absent composite is null as a whole, not a dictionary of nulls
136+
#expect(data.attributes["billingAddress"] == .null)
137+
// the absent element is null inside a composite that is present
138+
guard case let .composite(address)? = data.attributes["address"] else {
139+
Issue.record("Expected a composite address")
140+
return
141+
}
142+
#expect(address["city"] == .null)
143+
#expect(address["street"] == .string("1 Main"))
144+
}
145+
146+
/// Both nested composites are independently addressable when both are populated.
147+
static func assertTwoNestedComposites(_ store: some ModelStorage) async throws {
148+
let facility = facility(
149+
name: "North",
150+
street: "1 Main",
151+
latitude: 40.7,
152+
longitude: -74.0,
153+
billing: Address(
154+
street: "PO Box 9",
155+
city: "Shelbyville",
156+
location: Campground.LocationCoordinates(latitude: 1.5, longitude: 2.5)
157+
)
158+
)
159+
try await store.insert(facility)
160+
let fetched = try #require(try await store.fetch(Facility.self, for: facility.id))
161+
#expect(fetched.address.location.latitude == 40.7)
162+
#expect(fetched.billingAddress?.location.latitude == 1.5)
163+
#expect(fetched.billingAddress?.street == "PO Box 9")
164+
// each is reachable by its own nested key path
165+
let matched = try await store.fetch(
166+
Facility.self,
167+
predicate: "billingAddress.location.latitude" == 1.5
168+
)
169+
#expect(matched.map(\.name) == ["North"])
170+
}
171+
172+
/// Replacing a nested composite replaces it whole, at every level.
173+
static func assertNestedUpdate(_ store: some ModelStorage) async throws {
174+
var facility = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0)
175+
try await store.insert(facility)
176+
facility.address.location = Campground.LocationCoordinates(latitude: 1, longitude: 2)
177+
facility.address.street = "3 Elm"
178+
try await store.insert(facility)
179+
let fetched = try #require(try await store.fetch(Facility.self, for: facility.id))
180+
#expect(fetched.address.street == "3 Elm")
181+
#expect(fetched.address.location == Campground.LocationCoordinates(latitude: 1, longitude: 2))
182+
// setting the optional composite back to nil clears it
183+
facility.billingAddress = nil
184+
try await store.insert(facility)
185+
let cleared = try #require(try await store.fetch(Facility.self, for: facility.id))
186+
#expect(cleared.billingAddress == nil)
187+
}
188+
189+
// MARK: - In-memory store
190+
191+
@Test func inMemoryRoundTrip() async throws {
192+
try await Self.assertRoundTrip(InMemoryModelStorage(model: Self.model))
193+
}
194+
195+
@Test func inMemoryStoredValueIsNested() async throws {
196+
try await Self.assertStoredValueIsNested(InMemoryModelStorage(model: Self.model))
197+
}
198+
199+
@Test func inMemoryNestedPredicate() async throws {
200+
try await Self.assertNestedPredicate(InMemoryModelStorage(model: Self.model))
201+
}
202+
203+
@Test func inMemoryNestedSort() async throws {
204+
try await Self.assertNestedSort(InMemoryModelStorage(model: Self.model))
205+
}
206+
207+
@Test func inMemoryNullHandling() async throws {
208+
try await Self.assertNullHandling(InMemoryModelStorage(model: Self.model))
209+
}
210+
211+
@Test func inMemoryTwoNestedComposites() async throws {
212+
try await Self.assertTwoNestedComposites(InMemoryModelStorage(model: Self.model))
213+
}
214+
215+
@Test func inMemoryNestedUpdate() async throws {
216+
try await Self.assertNestedUpdate(InMemoryModelStorage(model: Self.model))
217+
}
218+
219+
// MARK: - CoreData
220+
221+
#if canImport(CoreData)
222+
223+
/// - Note: An explicit SQLite store, since CoreData refuses composite attributes
224+
/// in atomic (in-memory, XML, binary) stores.
225+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
226+
static func makeCoreDataStore() throws -> PersistentContainerStorage {
227+
let url = URL(fileURLWithPath: NSTemporaryDirectory())
228+
.appendingPathComponent("Nested-\(UUID()).sqlite")
229+
let description = NSPersistentStoreDescription(url: url)
230+
description.type = NSSQLiteStoreType
231+
description.shouldAddStoreAsynchronously = false
232+
return try PersistentContainerStorage(
233+
name: "Nested\(UUID())",
234+
model: Self.model,
235+
storeDescriptions: [description]
236+
)
237+
}
238+
239+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
240+
@Test func coreDataRoundTrip() async throws {
241+
try await Self.assertRoundTrip(try Self.makeCoreDataStore())
242+
}
243+
244+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
245+
@Test func coreDataStoredValueIsNested() async throws {
246+
try await Self.assertStoredValueIsNested(try Self.makeCoreDataStore())
247+
}
248+
249+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
250+
@Test func coreDataNestedPredicate() async throws {
251+
try await Self.assertNestedPredicate(try Self.makeCoreDataStore())
252+
}
253+
254+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
255+
@Test func coreDataNestedSort() async throws {
256+
try await Self.assertNestedSort(try Self.makeCoreDataStore())
257+
}
258+
259+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
260+
@Test func coreDataNullHandling() async throws {
261+
try await Self.assertNullHandling(try Self.makeCoreDataStore())
262+
}
263+
264+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
265+
@Test func coreDataTwoNestedComposites() async throws {
266+
try await Self.assertTwoNestedComposites(try Self.makeCoreDataStore())
267+
}
268+
269+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
270+
@Test func coreDataNestedUpdate() async throws {
271+
try await Self.assertNestedUpdate(try Self.makeCoreDataStore())
272+
}
273+
274+
/// The nested element list reaches CoreData as a nested `NSCompositeAttributeDescription`.
275+
@available(macOS 14, iOS 17, watchOS 10, tvOS 17, *)
276+
@Test func coreDataNestedSchema() throws {
277+
let managedObjectModel = try NSManagedObjectModel(model: Self.model)
278+
let entity = try #require(managedObjectModel.entitiesByName["Facility"])
279+
let address = try #require(entity.attributesByName["address"] as? NSCompositeAttributeDescription)
280+
#expect(address.elements.count == 3)
281+
let location = try #require(address.elements.first { $0.name == "location" } as? NSCompositeAttributeDescription)
282+
#expect(location.elements.count == 2)
283+
#expect(location.elements.map(\.name).sorted() == ["latitude", "longitude"])
284+
// elements are optional at every level
285+
#expect(address.elements.allSatisfy { $0.isOptional })
286+
#expect(location.elements.allSatisfy { $0.isOptional })
287+
// and the whole tree round trips back
288+
#expect(AttributeType(attribute: address) == Address.attributeType)
289+
}
290+
291+
#endif
292+
}

0 commit comments

Comments
 (0)