Skip to content

Commit 1cdf583

Browse files
authored
Merge pull request #12 from PureSwift/feature/composite-attribute
Composite attribute support
2 parents 8c09f9e + bdf48a5 commit 1cdf583

9 files changed

Lines changed: 404 additions & 13 deletions

File tree

‎Package.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ let package = Package(
2828
dependencies: [
2929
.package(
3030
url: "https://github.com/PureSwift/CoreModel",
31-
from: "2.10.1"
31+
from: "2.11.0"
3232
),
3333
.package(
3434
url: "https://github.com/PureSwift/SQLite",

‎Sources/CoreModelSQLite/Attribute.swift‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import SQLite
1010

1111
extension ColumnDefinition {
1212

13+
/// - Note: A composite attribute has no single column; use ``init(column:isOptional:)``
14+
/// with each of its expanded ``AttributeColumn`` values instead.
1315
init(
1416
attribute: Attribute,
1517
isOptional: Bool = true
1618
) {
19+
assert(attribute.type.isComposite == false, "Composite attributes are expanded into leaf columns")
1720
self.init(
1821
name: attribute.id.rawValue,
1922
primaryKey: nil,
@@ -24,4 +27,20 @@ extension ColumnDefinition {
2427
references: nil
2528
)
2629
}
30+
31+
/// A column contributed by an attribute, which for a composite is one of its leaves.
32+
init(
33+
column: AttributeColumn,
34+
isOptional: Bool = true
35+
) {
36+
self.init(
37+
name: column.name,
38+
primaryKey: nil,
39+
type: .init(attributeType: column.type),
40+
nullable: isOptional,
41+
unique: false,
42+
defaultValue: .NULL,
43+
references: nil
44+
)
45+
}
2746
}

‎Sources/CoreModelSQLite/AttributeType.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ internal extension ColumnDefinition.Affinity {
3939
// stored as a string to preserve precision;
4040
// NUMERIC affinity would coerce to REAL
4141
self = .TEXT
42+
case .composite:
43+
// Composite attributes are expanded into one column per leaf element, so a
44+
// composite type never reaches a column definition.
45+
assertionFailure("Composite attribute types are expanded into leaf columns")
46+
self = .BLOB
4247
}
4348
}
4449
}

‎Sources/CoreModelSQLite/AttributeValue.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ internal extension AttributeValue {
4646
return .double(value)
4747
case let .decimal(value):
4848
return .text(value.description)
49+
case .composite:
50+
// A composite has no single binding: it is expanded into one binding per leaf
51+
// column by `ModelData.columnValues(for:)`. Reaching here means a composite was
52+
// used where a scalar is required, e.g. as a predicate constant.
53+
return nil
4954
}
5055
}
5156

@@ -116,6 +121,10 @@ internal extension AttributeValue {
116121
throw SQLiteDatabaseError.invalidBinding(binding, type)
117122
}
118123
self = .decimal(value)
124+
case .composite:
125+
// Composites are reassembled from their leaf columns by
126+
// `AttributeValue.decode(attribute:row:)`, never from a single binding.
127+
throw SQLiteDatabaseError.invalidBinding(binding, type)
119128
}
120129
}
121130
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// CompositeAttribute.swift
3+
// CoreModel-SQLite
4+
//
5+
// Created by Alsey Coleman Miller on 8/16/26.
6+
//
7+
8+
import CoreModel
9+
import SQLite
10+
11+
// MARK: - Column Expansion
12+
13+
/// A scalar column contributed by an attribute.
14+
///
15+
/// A scalar attribute contributes a single column named after it. A composite attribute
16+
/// contributes one column per *leaf* element, at any nesting depth — the same expansion
17+
/// CoreData performs for `NSCompositeAttributeDescription`, so element key paths are
18+
/// ordinary column references rather than JSON extractions.
19+
///
20+
/// Columns are named by their full dotted path (`address.location.latitude`). CoreData
21+
/// instead names them after the leaf alone and disambiguates collisions with a numeric
22+
/// suffix, which obliges it to persist the mapping; a path name is deterministic and
23+
/// needs no bookkeeping. It also means ``PredicateKeyPath`` `rawValue` is already the
24+
/// column name, so predicates and sort terms need no rewriting.
25+
internal struct AttributeColumn: Equatable, Hashable {
26+
27+
/// The dotted column name, e.g. `address.location.latitude`.
28+
let name: String
29+
30+
/// The property path from the entity to this leaf.
31+
let path: [PropertyKey]
32+
33+
/// The leaf type. Never ``AttributeType/composite(_:)``.
34+
let type: AttributeType
35+
}
36+
37+
internal extension Array where Element == PropertyKey {
38+
39+
/// The dotted column name for a property path.
40+
var columnName: String {
41+
reduce("") { $0 + ($0.isEmpty ? "" : ".") + $1.rawValue }
42+
}
43+
}
44+
45+
internal extension Attribute {
46+
47+
/// The columns this attribute contributes, expanding composites into their leaves.
48+
var columns: [AttributeColumn] {
49+
Attribute.columns(id: id, type: type, parent: [])
50+
}
51+
52+
private static func columns(
53+
id: PropertyKey,
54+
type: AttributeType,
55+
parent: [PropertyKey]
56+
) -> [AttributeColumn] {
57+
let path = parent + [id]
58+
guard case let .composite(elements) = type else {
59+
return [AttributeColumn(name: path.columnName, path: path, type: type)]
60+
}
61+
return elements.flatMap { columns(id: $0.id, type: $0.type, parent: path) }
62+
}
63+
}
64+
65+
internal extension EntityDescription {
66+
67+
/// Every scalar attribute column of this entity, with composites expanded.
68+
var attributeColumns: [AttributeColumn] {
69+
attributes.flatMap { $0.columns }
70+
}
71+
}
72+
73+
// MARK: - Value Expansion
74+
75+
internal extension AttributeValue {
76+
77+
/// The value at a path within this value, descending through composite elements.
78+
///
79+
/// - Returns: `nil` when the path leaves the value, which a caller binds as SQL `NULL`.
80+
func value(at path: ArraySlice<PropertyKey>) -> AttributeValue? {
81+
guard let key = path.first else {
82+
return self
83+
}
84+
guard case let .composite(elements) = self, let element = elements[key] else {
85+
return nil
86+
}
87+
return element.value(at: path.dropFirst())
88+
}
89+
90+
/// Rebuild an attribute value from a row's expanded columns.
91+
///
92+
/// - Note: A composite whose every leaf is `NULL` decodes as `.null`. An expanded
93+
/// column layout has nowhere to record the difference between an absent composite and
94+
/// one whose elements are all null, so the two are indistinguishable — exactly as they
95+
/// are in CoreData, which stores composites the same way.
96+
static func decode(attribute: Attribute, row: [String: Binding?]) throws -> AttributeValue {
97+
try decode(id: attribute.id, type: attribute.type, parent: [], row: row)
98+
}
99+
100+
private static func decode(
101+
id: PropertyKey,
102+
type: AttributeType,
103+
parent: [PropertyKey],
104+
row: [String: Binding?]
105+
) throws -> AttributeValue {
106+
let path = parent + [id]
107+
guard case let .composite(elements) = type else {
108+
return try AttributeValue(binding: row[path.columnName] ?? nil, type: type)
109+
}
110+
var values = [PropertyKey: AttributeValue](minimumCapacity: elements.count)
111+
var isEmpty = true
112+
for element in elements {
113+
let value = try decode(id: element.id, type: element.type, parent: path, row: row)
114+
if value != .null {
115+
isEmpty = false
116+
}
117+
values[element.id] = value
118+
}
119+
return isEmpty ? .null : .composite(values)
120+
}
121+
}

‎Sources/CoreModelSQLite/EntityDescription.swift‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ internal extension SchemaChanger.CreateTableDefinition {
3333
primaryKey: .init(autoIncrement: false), type: .TEXT, nullable: false, unique: true, defaultValue: .NULL, references: nil)
3434
add(column: id)
3535

36-
// add attribute columns
37-
for attribute in entity.attributes {
38-
add(column: ColumnDefinition(attribute: attribute))
36+
// add attribute columns, expanding composite attributes into one column per leaf
37+
for column in entity.attributeColumns {
38+
add(column: ColumnDefinition(column: column))
3939
}
4040

4141
// add to-one relationship columns

‎Sources/CoreModelSQLite/ModelData.swift‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ internal extension ModelData {
2323
assert(entity.id == self.entity)
2424
var values: [(String, Binding?)] = [(SQLiteDatabase.primaryKeyColumn, id.rawValue.binding)]
2525
values.reserveCapacity(1 + entity.attributes.count + entity.relationships.count)
26-
for attribute in entity.attributes {
27-
let value = attributes[attribute.id] ?? .null
28-
values.append((attribute.id.rawValue, value.binding))
26+
// composite attributes contribute one column per leaf element
27+
for column in entity.attributeColumns {
28+
let root = attributes[column.path[0]] ?? .null
29+
let value = root.value(at: column.path.dropFirst()) ?? .null
30+
values.append((column.name, value.binding))
2931
}
3032
for relationship in entity.relationships where relationship.type == .toOne {
3133
let binding: Binding?
@@ -47,8 +49,8 @@ internal extension ModelData {
4749
/// DO UPDATE` that only overwrites columns the caller actually supplied.
4850
func providedColumnNames(for entity: EntityDescription) -> Set<String> {
4951
var names = Set<String>()
50-
for attribute in entity.attributes where attributes[attribute.id] != nil {
51-
names.insert(attribute.id.rawValue)
52+
for column in entity.attributeColumns where attributes[column.path[0]] != nil {
53+
names.insert(column.name)
5254
}
5355
for relationship in entity.relationships where relationship.type == .toOne && relationships[relationship.id] != nil {
5456
names.insert(relationship.id.rawValue)
@@ -68,8 +70,7 @@ internal extension ModelData {
6870
var attributes = [PropertyKey: AttributeValue]()
6971
attributes.reserveCapacity(entity.attributes.count)
7072
for attribute in entity.attributes {
71-
let binding = row[attribute.id.rawValue] ?? nil
72-
attributes[attribute.id] = try AttributeValue(binding: binding, type: attribute.type)
73+
attributes[attribute.id] = try AttributeValue.decode(attribute: attribute, row: row)
7374
}
7475
var relationships = [PropertyKey: RelationshipValue]()
7576
relationships.reserveCapacity(entity.relationships.count)

‎Sources/CoreModelSQLite/Predicate.swift‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ private extension FetchRequest.Predicate.Expression {
274274
return try function.sqlFragment(for: entity, predicate: predicate)
275275
case .attribute, .relationship:
276276
return SQLFragment(sql: "?", bindings: [try constantBinding(predicate: predicate)])
277+
case .arithmetic:
278+
// - TODO: Translate arithmetic expressions to SQL. Until then they are
279+
// rejected so the caller can fall back to in-memory evaluation.
280+
throw SQLiteDatabaseError.invalidPredicate(predicate)
277281
}
278282
}
279283

@@ -291,7 +295,7 @@ private extension FetchRequest.Predicate.Expression {
291295
case .toMany:
292296
throw SQLiteDatabaseError.invalidPredicate(predicate)
293297
}
294-
case .keyPath, .function:
298+
case .keyPath, .function, .arithmetic:
295299
throw SQLiteDatabaseError.invalidPredicate(predicate)
296300
}
297301
}
@@ -336,7 +340,10 @@ internal extension EntityDescription {
336340
if property.rawValue == SQLiteDatabase.primaryKeyColumn {
337341
return true
338342
}
339-
if attributes.contains(where: { $0.id == property }) {
343+
// Composite attributes are expanded into leaf columns named by dotted path, so an
344+
// element key path such as `address.location.latitude` matches its column directly.
345+
// A composite attribute *as a whole* deliberately does not: it has no single column.
346+
if attributeColumns.contains(where: { $0.name == property.rawValue }) {
340347
return true
341348
}
342349
return relationships.contains(where: { $0.id == property && $0.type == .toOne })

0 commit comments

Comments
 (0)