Skip to content

Commit 8a6ac0d

Browse files
authored
Merge pull request #36 from PureSwift/feature/composite-attribute
Add Composite Attribute Support
2 parents 04ecd57 + e1be582 commit 8a6ac0d

37 files changed

Lines changed: 2444 additions & 219 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// CoreDataModelError.swift
3+
// CoreDataModel
4+
//
5+
// Created by Alsey Coleman Miller on 8/16/26.
6+
//
7+
8+
#if canImport(CoreData)
9+
import Foundation
10+
import CoreModel
11+
12+
/// An error building a CoreData model from a CoreModel ``Model``.
13+
public enum CoreDataModelError: Error {
14+
15+
/// The model declares a composite attribute, which requires
16+
/// macOS 14, iOS 17, tvOS 17, or watchOS 10 or later.
17+
case compositeAttributesUnavailable(EntityName, PropertyKey)
18+
19+
/// A composite attribute declared no elements.
20+
///
21+
/// CoreData rejects a composite type with an empty element list.
22+
case emptyCompositeAttribute(PropertyKey)
23+
24+
/// The stored value for a composite attribute did not match its element descriptions.
25+
case invalidCompositeValue(PropertyKey)
26+
}
27+
28+
// MARK: - CustomNSError
29+
30+
extension CoreDataModelError: CustomNSError {
31+
32+
public static var errorDomain: String { "org.pureswift.CoreDataModel.CoreDataModelError" }
33+
34+
public var errorCode: Int {
35+
switch self {
36+
case .compositeAttributesUnavailable: return 1
37+
case .emptyCompositeAttribute: return 2
38+
case .invalidCompositeValue: return 3
39+
}
40+
}
41+
42+
public var errorUserInfo: [String: Any] {
43+
[NSLocalizedDescriptionKey: description]
44+
}
45+
}
46+
47+
// MARK: - CustomStringConvertible
48+
49+
extension CoreDataModelError: CustomStringConvertible {
50+
51+
public var description: String {
52+
switch self {
53+
case let .compositeAttributesUnavailable(entity, key):
54+
return "Composite attribute \(entity).\(key) requires macOS 14, iOS 17, tvOS 17, or watchOS 10 or later."
55+
case let .emptyCompositeAttribute(key):
56+
return "Composite attribute \(key) must declare at least one element."
57+
case let .invalidCompositeValue(key):
58+
return "Invalid value for composite attribute \(key)."
59+
}
60+
}
61+
}
62+
63+
#endif

‎Sources/CoreDataModel/NSAttributeDescription.swift‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//
22
// NSAttributeDescription.swift
3-
//
3+
//
44
//
55
// Created by Alsey Coleman Miller on 8/17/23.
66
//
@@ -11,16 +11,56 @@ import CoreData
1111
import CoreModel
1212

1313
public extension NSAttributeDescription {
14-
14+
15+
/// - Warning: A composite attribute needs an `NSCompositeAttributeDescription`,
16+
/// which this initializer cannot return. Use ``make(attribute:isOptional:)`` instead.
1517
convenience init(
1618
attribute: Attribute,
1719
isOptional: Bool = true
1820
) {
1921
self.init()
2022
self.name = attribute.id.rawValue
2123
self.isOptional = isOptional
24+
assert(attribute.type.isComposite == false, "Use NSAttributeDescription.make(attribute:) for composite attributes")
2225
self.attributeType = .init(attributeType: attribute.type)
2326
}
2427
}
2528

29+
internal extension NSAttributeDescription {
30+
31+
/// Build the CoreData property description for a CoreModel attribute.
32+
///
33+
/// A composite attribute produces an `NSCompositeAttributeDescription`, which is a
34+
/// different class and so cannot come from a `convenience init` on this one.
35+
///
36+
/// - Note: CoreData's two other constraints on elements hold by construction here:
37+
/// an element can never be a relationship (``Attribute`` has no relationship type),
38+
/// and the element tree is a finite value, so it cannot be recursive.
39+
static func make(attribute: Attribute, isOptional: Bool = true) throws -> NSAttributeDescription {
40+
guard case let .composite(elements) = attribute.type else {
41+
return NSAttributeDescription(attribute: attribute, isOptional: isOptional)
42+
}
43+
guard elements.isEmpty == false else {
44+
throw CoreDataModelError.emptyCompositeAttribute(attribute.id)
45+
}
46+
guard #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) else {
47+
throw CoreDataModelError.compositeAttributesUnavailable("", attribute.id)
48+
}
49+
let description = NSCompositeAttributeDescription()
50+
description.name = attribute.id.rawValue
51+
description.isOptional = isOptional
52+
description.attributeType = .composite
53+
// - Note: Elements are always optional. CoreData raises a "missing mandatory
54+
// data" validation fault on save when a non-optional element is absent, and
55+
// `momc` does not diagnose it at build time.
56+
var children = [NSAttributeDescription]()
57+
children.reserveCapacity(elements.count)
58+
for element in elements {
59+
children.append(try make(attribute: element, isOptional: true))
60+
}
61+
description.elements = children
62+
return description
63+
}
64+
}
65+
2666
#endif

‎Sources/CoreDataModel/NSAttributeType.swift‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,22 @@ import Foundation
1010
import CoreData
1111
import CoreModel
1212

13+
internal extension NSAttributeType {
14+
15+
/// `NSCompositeAttributeType` (2100).
16+
///
17+
/// - Note: Spelled by raw value so that referencing it doesn't depend on the
18+
/// availability annotations of the imported enum case. The
19+
/// `NSCompositeAttributeDescription` *class* still requires macOS 14 / iOS 17.
20+
static var composite: NSAttributeType { NSAttributeType(rawValue: 2100)! }
21+
}
22+
1323
public extension NSAttributeType {
14-
24+
1525
init(attributeType: AttributeType) {
1626
switch attributeType {
27+
case .composite:
28+
self = .composite
1729
case .bool:
1830
self = .booleanAttributeType
1931
case .int16:
@@ -43,7 +55,40 @@ public extension NSAttributeType {
4355
}
4456

4557
public extension AttributeType {
46-
58+
59+
/// Reconstruct the CoreModel attribute type from a CoreData attribute description,
60+
/// including composite attributes and their (possibly nested) elements.
61+
///
62+
/// Prefer this over ``init(attributeType:)``, which cannot represent composites:
63+
/// the elements live on the description, not on the `NSAttributeType`.
64+
init?(attribute: NSAttributeDescription) {
65+
guard attribute.attributeType == .composite else {
66+
guard let type = AttributeType(attributeType: attribute.attributeType) else {
67+
return nil
68+
}
69+
self = type
70+
return
71+
}
72+
guard #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) else {
73+
return nil
74+
}
75+
guard let composite = attribute as? NSCompositeAttributeDescription else {
76+
return nil
77+
}
78+
var elements = [Attribute]()
79+
elements.reserveCapacity(composite.elements.count)
80+
for element in composite.elements {
81+
// recursion handles nested composites
82+
guard let type = AttributeType(attribute: element) else {
83+
return nil
84+
}
85+
elements.append(Attribute(id: PropertyKey(rawValue: element.name), type: type))
86+
}
87+
self = .composite(elements)
88+
}
89+
90+
/// - Note: Returns `nil` for `.compositeAttributeType`, whose elements cannot be
91+
/// recovered from the type alone. Use ``init(attribute:)`` to round-trip composites.
4792
init?(attributeType: NSAttributeType) {
4893
switch attributeType {
4994
case .undefinedAttributeType:

‎Sources/CoreDataModel/NSEntityDescription.swift‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import CoreModel
1212

1313
internal extension NSEntityDescription {
1414

15-
convenience init(entity: EntityDescription) {
15+
convenience init(entity: EntityDescription) throws {
1616
self.init()
1717
self.name = entity.id.rawValue
1818
// add id attribute
@@ -27,7 +27,15 @@ internal extension NSEntityDescription {
2727
var properties = [NSPropertyDescription]()
2828
properties.reserveCapacity(entity.attributes.count + entity.relationships.count + 1)
2929
properties.append(id)
30-
properties += entity.attributes.map { NSAttributeDescription(attribute: $0) }
30+
for attribute in entity.attributes {
31+
do {
32+
properties.append(try NSAttributeDescription.make(attribute: attribute))
33+
}
34+
catch CoreDataModelError.compositeAttributesUnavailable(_, let key) {
35+
// re-throw with the entity name attached
36+
throw CoreDataModelError.compositeAttributesUnavailable(entity.id, key)
37+
}
38+
}
3139
properties += entity.relationships.map { NSRelationshipDescription(relationship: $0) }
3240
self.properties = properties
3341
self.uniquenessConstraints = [[NSManagedObject.BuiltInProperty.id.rawValue as NSString]]

0 commit comments

Comments
 (0)