Skip to content

Commit f5ac1cb

Browse files
committed
Composite attributes: Add tests
1 parent c2d86fe commit f5ac1cb

1 file changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//
2+
// CompositeAttributeTests.swift
3+
// CoreModel-SQLite
4+
//
5+
// Created by Alsey Coleman Miller on 8/16/26.
6+
//
7+
8+
import Foundation
9+
import Testing
10+
import CoreModel
11+
import SQLite
12+
@testable import CoreModelSQLite
13+
14+
/// Composite attributes, which are expanded into one column per leaf element.
15+
@Suite struct CompositeAttributeTests {
16+
17+
/// `location` is a flat composite; `address` nests one composite inside another.
18+
static var model: Model { Model(entities: [
19+
EntityDescription(
20+
id: "Facility",
21+
attributes: [
22+
.init(id: "name", type: .string),
23+
.init(id: "location", elements: [
24+
.init(id: "latitude", type: .double),
25+
.init(id: "longitude", type: .double)
26+
]),
27+
.init(id: "address", elements: [
28+
.init(id: "street", type: .string),
29+
.init(id: "location", elements: [
30+
.init(id: "latitude", type: .double),
31+
.init(id: "longitude", type: .double)
32+
])
33+
])
34+
],
35+
relationships: []
36+
)
37+
]) }
38+
39+
static func makeDatabase() throws -> SQLiteDatabase {
40+
try SQLiteDatabase(path: temporaryDatabasePath(named: "Composite"), model: model)
41+
}
42+
43+
static func facility(
44+
id: ObjectID,
45+
name: String,
46+
latitude: Double,
47+
longitude: Double,
48+
street: String = "1 Main",
49+
innerLatitude: Double = 1.5
50+
) -> ModelData {
51+
ModelData(
52+
entity: "Facility",
53+
id: id,
54+
attributes: [
55+
"name": .string(name),
56+
"location": .composite([
57+
"latitude": .double(latitude),
58+
"longitude": .double(longitude)
59+
]),
60+
"address": .composite([
61+
"street": .string(street),
62+
"location": .composite([
63+
"latitude": .double(innerLatitude),
64+
"longitude": .double(2.5)
65+
])
66+
])
67+
]
68+
)
69+
}
70+
71+
// MARK: - Column expansion
72+
73+
@Test func columnExpansion() {
74+
let entity = Self.model.entities[0]
75+
let names = entity.attributeColumns.map(\.name).sorted()
76+
#expect(names == [
77+
"address.location.latitude",
78+
"address.location.longitude",
79+
"address.street",
80+
"location.latitude",
81+
"location.longitude",
82+
"name"
83+
])
84+
// every expanded column is a scalar type
85+
#expect(entity.attributeColumns.allSatisfy { $0.type.isComposite == false })
86+
// and the leaf path is preserved
87+
let deep = entity.attributeColumns.first { $0.name == "address.location.latitude" }
88+
#expect(deep?.path == ["address", "location", "latitude"])
89+
#expect(deep?.type == .double)
90+
}
91+
92+
/// The table has a real, natively typed column per leaf — no JSON, no blob.
93+
@Test func schemaHasLeafColumns() throws {
94+
let path = temporaryDatabasePath(named: "CompositeSchema")
95+
_ = try SQLiteDatabase(path: path, model: Self.model)
96+
let reader = try Connection(path: path, isReadOnly: true)
97+
let statement = try reader.prepare("SELECT name, type FROM pragma_table_info('Facility')")
98+
var byName = [String: String]()
99+
while let row = try statement.failableNext() {
100+
byName[row[0]?.textValue ?? ""] = row[1]?.textValue ?? ""
101+
}
102+
#expect(byName["location.latitude"] == "REAL")
103+
#expect(byName["address.location.latitude"] == "REAL")
104+
#expect(byName["address.street"] == "TEXT")
105+
// the composite itself is not a column
106+
#expect(byName["location"] == nil)
107+
#expect(byName["address"] == nil)
108+
}
109+
110+
// MARK: - Round trip
111+
112+
@Test func compositeRoundTrip() async throws {
113+
let database = try Self.makeDatabase()
114+
let facility = Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0)
115+
try await database.insert(facility)
116+
let fetched = try #require(try await database.fetch("Facility", for: "north"))
117+
#expect(fetched.attributes["location"] == .composite([
118+
"latitude": .double(40.7),
119+
"longitude": .double(-74.0)
120+
]))
121+
#expect(fetched.attributes["address"] == .composite([
122+
"street": .string("1 Main"),
123+
"location": .composite([
124+
"latitude": .double(1.5),
125+
"longitude": .double(2.5)
126+
])
127+
]))
128+
}
129+
130+
@Test func compositeUpdate() async throws {
131+
let database = try Self.makeDatabase()
132+
try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0))
133+
try await database.insert(Self.facility(id: "north", name: "North", latitude: 1.0, longitude: 2.0, street: "3 Elm"))
134+
let fetched = try #require(try await database.fetch("Facility", for: "north"))
135+
#expect(fetched.attributes["location"] == .composite([
136+
"latitude": .double(1.0),
137+
"longitude": .double(2.0)
138+
]))
139+
guard case let .composite(address)? = fetched.attributes["address"] else {
140+
Issue.record("expected composite address")
141+
return
142+
}
143+
#expect(address["street"] == .string("3 Elm"))
144+
}
145+
146+
/// An expanded column layout cannot distinguish an absent composite from one whose
147+
/// elements are all null — the same lossy behavior CoreData has.
148+
@Test func absentCompositeIsNull() async throws {
149+
let database = try Self.makeDatabase()
150+
let bare = ModelData(entity: "Facility", id: "bare", attributes: ["name": .string("Bare")])
151+
try await database.insert(bare)
152+
let fetched = try #require(try await database.fetch("Facility", for: "bare"))
153+
#expect(fetched.attributes["location"] == .null)
154+
#expect(fetched.attributes["address"] == .null)
155+
}
156+
157+
/// A partially specified composite keeps its set elements and nulls the rest.
158+
@Test func partialComposite() async throws {
159+
let database = try Self.makeDatabase()
160+
let partial = ModelData(
161+
entity: "Facility",
162+
id: "partial",
163+
attributes: [
164+
"name": .string("Partial"),
165+
"location": .composite(["latitude": .double(40.7)])
166+
]
167+
)
168+
try await database.insert(partial)
169+
let fetched = try #require(try await database.fetch("Facility", for: "partial"))
170+
#expect(fetched.attributes["location"] == .composite([
171+
"latitude": .double(40.7),
172+
"longitude": .null
173+
]))
174+
}
175+
176+
// MARK: - Predicates and sorting
177+
178+
@Test func elementPredicate() async throws {
179+
let database = try Self.makeDatabase()
180+
try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0))
181+
try await database.insert(Self.facility(id: "south", name: "South", latitude: 25.8, longitude: -80.2))
182+
let results = try await database.fetch(FetchRequest(
183+
entity: "Facility",
184+
predicate: "location.latitude" > 30
185+
))
186+
#expect(results.map(\.id) == ["north"])
187+
}
188+
189+
/// A key path two levels deep is still an ordinary column reference.
190+
@Test func nestedElementPredicate() async throws {
191+
let database = try Self.makeDatabase()
192+
try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0, innerLatitude: 9.5))
193+
try await database.insert(Self.facility(id: "south", name: "South", latitude: 25.8, longitude: -80.2, innerLatitude: 1.5))
194+
let results = try await database.fetch(FetchRequest(
195+
entity: "Facility",
196+
predicate: "address.location.latitude" > 5
197+
))
198+
#expect(results.map(\.id) == ["north"])
199+
}
200+
201+
@Test func elementSort() async throws {
202+
let database = try Self.makeDatabase()
203+
try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0))
204+
try await database.insert(Self.facility(id: "south", name: "South", latitude: 25.8, longitude: -80.2))
205+
let ascending = try await database.fetch(FetchRequest(
206+
entity: "Facility",
207+
sortDescriptors: [.init(property: "location.latitude", ascending: true)]
208+
))
209+
#expect(ascending.map(\.id) == ["south", "north"])
210+
let descending = try await database.fetch(FetchRequest(
211+
entity: "Facility",
212+
sortDescriptors: [.init(property: "address.location.latitude", ascending: false)]
213+
))
214+
#expect(descending.count == 2)
215+
}
216+
217+
/// A composite has no single column, so comparing one as a whole is rejected rather
218+
/// than silently mis-compiled.
219+
@Test func wholeCompositePredicateRejected() async throws {
220+
let database = try Self.makeDatabase()
221+
try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0))
222+
await #expect(throws: (any Error).self) {
223+
try await database.fetch(FetchRequest(
224+
entity: "Facility",
225+
predicate: "location".compare(.equalTo, .attribute(.composite(["latitude": .double(40.7)])))
226+
))
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)