diff --git a/Sources/CoreDataModel/CoreDataModelError.swift b/Sources/CoreDataModel/CoreDataModelError.swift new file mode 100644 index 0000000..be84213 --- /dev/null +++ b/Sources/CoreDataModel/CoreDataModelError.swift @@ -0,0 +1,63 @@ +// +// CoreDataModelError.swift +// CoreDataModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +#if canImport(CoreData) +import Foundation +import CoreModel + +/// An error building a CoreData model from a CoreModel ``Model``. +public enum CoreDataModelError: Error { + + /// The model declares a composite attribute, which requires + /// macOS 14, iOS 17, tvOS 17, or watchOS 10 or later. + case compositeAttributesUnavailable(EntityName, PropertyKey) + + /// A composite attribute declared no elements. + /// + /// CoreData rejects a composite type with an empty element list. + case emptyCompositeAttribute(PropertyKey) + + /// The stored value for a composite attribute did not match its element descriptions. + case invalidCompositeValue(PropertyKey) +} + +// MARK: - CustomNSError + +extension CoreDataModelError: CustomNSError { + + public static var errorDomain: String { "org.pureswift.CoreDataModel.CoreDataModelError" } + + public var errorCode: Int { + switch self { + case .compositeAttributesUnavailable: return 1 + case .emptyCompositeAttribute: return 2 + case .invalidCompositeValue: return 3 + } + } + + public var errorUserInfo: [String: Any] { + [NSLocalizedDescriptionKey: description] + } +} + +// MARK: - CustomStringConvertible + +extension CoreDataModelError: CustomStringConvertible { + + public var description: String { + switch self { + case let .compositeAttributesUnavailable(entity, key): + return "Composite attribute \(entity).\(key) requires macOS 14, iOS 17, tvOS 17, or watchOS 10 or later." + case let .emptyCompositeAttribute(key): + return "Composite attribute \(key) must declare at least one element." + case let .invalidCompositeValue(key): + return "Invalid value for composite attribute \(key)." + } + } +} + +#endif diff --git a/Sources/CoreDataModel/NSAttributeDescription.swift b/Sources/CoreDataModel/NSAttributeDescription.swift index 7c3aa72..4665a98 100644 --- a/Sources/CoreDataModel/NSAttributeDescription.swift +++ b/Sources/CoreDataModel/NSAttributeDescription.swift @@ -1,6 +1,6 @@ // // NSAttributeDescription.swift -// +// // // Created by Alsey Coleman Miller on 8/17/23. // @@ -11,7 +11,9 @@ import CoreData import CoreModel public extension NSAttributeDescription { - + + /// - Warning: A composite attribute needs an `NSCompositeAttributeDescription`, + /// which this initializer cannot return. Use ``make(attribute:isOptional:)`` instead. convenience init( attribute: Attribute, isOptional: Bool = true @@ -19,8 +21,46 @@ public extension NSAttributeDescription { self.init() self.name = attribute.id.rawValue self.isOptional = isOptional + assert(attribute.type.isComposite == false, "Use NSAttributeDescription.make(attribute:) for composite attributes") self.attributeType = .init(attributeType: attribute.type) } } +internal extension NSAttributeDescription { + + /// Build the CoreData property description for a CoreModel attribute. + /// + /// A composite attribute produces an `NSCompositeAttributeDescription`, which is a + /// different class and so cannot come from a `convenience init` on this one. + /// + /// - Note: CoreData's two other constraints on elements hold by construction here: + /// an element can never be a relationship (``Attribute`` has no relationship type), + /// and the element tree is a finite value, so it cannot be recursive. + static func make(attribute: Attribute, isOptional: Bool = true) throws -> NSAttributeDescription { + guard case let .composite(elements) = attribute.type else { + return NSAttributeDescription(attribute: attribute, isOptional: isOptional) + } + guard elements.isEmpty == false else { + throw CoreDataModelError.emptyCompositeAttribute(attribute.id) + } + guard #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) else { + throw CoreDataModelError.compositeAttributesUnavailable("", attribute.id) + } + let description = NSCompositeAttributeDescription() + description.name = attribute.id.rawValue + description.isOptional = isOptional + description.attributeType = .composite + // - Note: Elements are always optional. CoreData raises a "missing mandatory + // data" validation fault on save when a non-optional element is absent, and + // `momc` does not diagnose it at build time. + var children = [NSAttributeDescription]() + children.reserveCapacity(elements.count) + for element in elements { + children.append(try make(attribute: element, isOptional: true)) + } + description.elements = children + return description + } +} + #endif diff --git a/Sources/CoreDataModel/NSAttributeType.swift b/Sources/CoreDataModel/NSAttributeType.swift index ffcfe2d..6f3f22b 100644 --- a/Sources/CoreDataModel/NSAttributeType.swift +++ b/Sources/CoreDataModel/NSAttributeType.swift @@ -10,10 +10,22 @@ import Foundation import CoreData import CoreModel +internal extension NSAttributeType { + + /// `NSCompositeAttributeType` (2100). + /// + /// - Note: Spelled by raw value so that referencing it doesn't depend on the + /// availability annotations of the imported enum case. The + /// `NSCompositeAttributeDescription` *class* still requires macOS 14 / iOS 17. + static var composite: NSAttributeType { NSAttributeType(rawValue: 2100)! } +} + public extension NSAttributeType { - + init(attributeType: AttributeType) { switch attributeType { + case .composite: + self = .composite case .bool: self = .booleanAttributeType case .int16: @@ -43,7 +55,40 @@ public extension NSAttributeType { } public extension AttributeType { - + + /// Reconstruct the CoreModel attribute type from a CoreData attribute description, + /// including composite attributes and their (possibly nested) elements. + /// + /// Prefer this over ``init(attributeType:)``, which cannot represent composites: + /// the elements live on the description, not on the `NSAttributeType`. + init?(attribute: NSAttributeDescription) { + guard attribute.attributeType == .composite else { + guard let type = AttributeType(attributeType: attribute.attributeType) else { + return nil + } + self = type + return + } + guard #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) else { + return nil + } + guard let composite = attribute as? NSCompositeAttributeDescription else { + return nil + } + var elements = [Attribute]() + elements.reserveCapacity(composite.elements.count) + for element in composite.elements { + // recursion handles nested composites + guard let type = AttributeType(attribute: element) else { + return nil + } + elements.append(Attribute(id: PropertyKey(rawValue: element.name), type: type)) + } + self = .composite(elements) + } + + /// - Note: Returns `nil` for `.compositeAttributeType`, whose elements cannot be + /// recovered from the type alone. Use ``init(attribute:)`` to round-trip composites. init?(attributeType: NSAttributeType) { switch attributeType { case .undefinedAttributeType: diff --git a/Sources/CoreDataModel/NSEntityDescription.swift b/Sources/CoreDataModel/NSEntityDescription.swift index 07760c3..6a4a90a 100644 --- a/Sources/CoreDataModel/NSEntityDescription.swift +++ b/Sources/CoreDataModel/NSEntityDescription.swift @@ -12,7 +12,7 @@ import CoreModel internal extension NSEntityDescription { - convenience init(entity: EntityDescription) { + convenience init(entity: EntityDescription) throws { self.init() self.name = entity.id.rawValue // add id attribute @@ -27,7 +27,15 @@ internal extension NSEntityDescription { var properties = [NSPropertyDescription]() properties.reserveCapacity(entity.attributes.count + entity.relationships.count + 1) properties.append(id) - properties += entity.attributes.map { NSAttributeDescription(attribute: $0) } + for attribute in entity.attributes { + do { + properties.append(try NSAttributeDescription.make(attribute: attribute)) + } + catch CoreDataModelError.compositeAttributesUnavailable(_, let key) { + // re-throw with the entity name attached + throw CoreDataModelError.compositeAttributesUnavailable(entity.id, key) + } + } properties += entity.relationships.map { NSRelationshipDescription(relationship: $0) } self.properties = properties self.uniquenessConstraints = [[NSManagedObject.BuiltInProperty.id.rawValue as NSString]] diff --git a/Sources/CoreDataModel/NSManagedObject.swift b/Sources/CoreDataModel/NSManagedObject.swift index f498ddc..7e1ae89 100644 --- a/Sources/CoreDataModel/NSManagedObject.swift +++ b/Sources/CoreDataModel/NSManagedObject.swift @@ -18,133 +18,108 @@ internal extension NSManagedObject { } } -internal extension NSManagedObject { - - func attribute(for key: PropertyKey) throws -> AttributeValue { - - guard let objectValue = self.value(forKey: key.rawValue) - else { return .null } - - guard let coreDataAttribute = entity.attributesByName[key.rawValue] else { - assertionFailure("Unknown CoreData attribute \(key)") - throw CocoaError(.coreData) - } - - guard let attributeType = AttributeType(attributeType: coreDataAttribute.attributeType) else { - assertionFailure("Invalid CoreData attribute \(coreDataAttribute)") - throw CocoaError(.coreData) - } - - switch attributeType { +internal extension AttributeValue { + + /// Decode a CoreData object value using the attribute type declared by the schema. + /// + /// The declared type is required, not merely convenient: an `NSNumber` alone can't + /// distinguish a bool from an `int16` from a `double`. + init(coreDataValue: Any, type: AttributeType, key: PropertyKey) throws { + switch type { case .bool: - guard let value = objectValue as? Bool else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .bool(value) + guard let value = coreDataValue as? Bool else { throw Self.invalid(coreDataValue, key) } + self = .bool(value) case .int16: - guard let value = objectValue as? Int16 else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .int16(value) + guard let value = coreDataValue as? Int16 else { throw Self.invalid(coreDataValue, key) } + self = .int16(value) case .int32: - guard let value = objectValue as? Int32 else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .int32(value) + guard let value = coreDataValue as? Int32 else { throw Self.invalid(coreDataValue, key) } + self = .int32(value) case .int64: - guard let value = objectValue as? Int64 else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .int64(value) + guard let value = coreDataValue as? Int64 else { throw Self.invalid(coreDataValue, key) } + self = .int64(value) case .float: - guard let value = objectValue as? Float else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .float(value) + guard let value = coreDataValue as? Float else { throw Self.invalid(coreDataValue, key) } + self = .float(value) case .double: - guard let value = objectValue as? Double else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .double(value) + guard let value = coreDataValue as? Double else { throw Self.invalid(coreDataValue, key) } + self = .double(value) case .string: - guard let value = objectValue as? String else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .string(value) + guard let value = coreDataValue as? String else { throw Self.invalid(coreDataValue, key) } + self = .string(value) case .data: - guard let value = objectValue as? Data else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .data(value) + guard let value = coreDataValue as? Data else { throw Self.invalid(coreDataValue, key) } + self = .data(value) case .date: - guard let value = objectValue as? Date else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .date(value) + guard let value = coreDataValue as? Date else { throw Self.invalid(coreDataValue, key) } + self = .date(value) case .uuid: - guard let value = objectValue as? UUID else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .uuid(value) + guard let value = coreDataValue as? UUID else { throw Self.invalid(coreDataValue, key) } + self = .uuid(value) case .url: - guard let value = objectValue as? URL else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) - } - return .url(value) + guard let value = coreDataValue as? URL else { throw Self.invalid(coreDataValue, key) } + self = .url(value) case .decimal: - guard let value = objectValue as? NSDecimalNumber else { - assertionFailure("Invalid CoreData attribute value \(objectValue)") - throw CocoaError(.coreData) + guard let value = coreDataValue as? NSDecimalNumber else { throw Self.invalid(coreDataValue, key) } + self = .decimal(value as Decimal) + case let .composite(elements): + guard let dictionary = coreDataValue as? [String: Any] else { + throw CoreDataModelError.invalidCompositeValue(key) } - return .decimal(value as Decimal) + self = .composite(try AttributeValue.composite(from: dictionary, elements: elements)) } } - - func setAttribute(_ newValue: AttributeValue, for key: PropertyKey) { + + /// Convert the dictionary CoreData stores for a composite attribute. + /// + /// Iterates the declared elements rather than the stored dictionary, so that a stale + /// or unknown key is ignored rather than mis-typed, and materializes an absent + /// element as `.null` so that the shape always matches the schema. + static func composite( + from dictionary: [String: Any], + elements: [Attribute] + ) throws -> [PropertyKey: AttributeValue] { + var values = [PropertyKey: AttributeValue](minimumCapacity: elements.count) + for element in elements { + // elements are always optional, so a missing key and NSNull both mean null + guard let raw = dictionary[element.id.rawValue], raw is NSNull == false else { + values[element.id] = .null + continue + } + values[element.id] = try AttributeValue(coreDataValue: raw, type: element.type, key: element.id) + } + return values + } + + private static func invalid(_ value: Any, _ key: PropertyKey) -> any Error { + assertionFailure("Invalid CoreData attribute value \(value) for \(key)") + return CocoaError(.coreData) + } +} + +internal extension NSManagedObject { + + func attribute(for key: PropertyKey) throws -> AttributeValue { - let objectValue: AnyObject? + guard let objectValue = self.value(forKey: key.rawValue) + else { return .null } - switch newValue { - case .null: - objectValue = nil - case let .string(value): - objectValue = value as NSString - case let .uuid(value): - objectValue = value as NSUUID - case let .url(value): - objectValue = value as NSURL - case let .data(value): - objectValue = value as NSData - case let .date(value): - objectValue = value as NSDate - case let .bool(value): - objectValue = value as NSNumber - case let .int16(value): - objectValue = value as NSNumber - case let .int32(value): - objectValue = value as NSNumber - case let .int64(value): - objectValue = value as NSNumber - case let .float(value): - objectValue = value as NSNumber - case let .double(value): - objectValue = value as NSNumber - case let .decimal(value): - objectValue = value as NSDecimalNumber + guard let coreDataAttribute = entity.attributesByName[key.rawValue] else { + assertionFailure("Unknown CoreData attribute \(key)") + throw CocoaError(.coreData) } - self.setValue(objectValue, forKey: key.rawValue) + guard let attributeType = AttributeType(attribute: coreDataAttribute) else { + assertionFailure("Invalid CoreData attribute \(coreDataAttribute)") + throw CocoaError(.coreData) + } + + return try AttributeValue(coreDataValue: objectValue, type: attributeType, key: key) + } + + func setAttribute(_ newValue: AttributeValue, for key: PropertyKey) { + + self.setValue(newValue.toFoundation(), forKey: key.rawValue) } func relationship(for key: PropertyKey) throws -> RelationshipValue { @@ -257,7 +232,7 @@ internal extension NSManagedObject { attributes.reserveCapacity(attributesByName.count) for (key, attribute) in attributesByName { guard NSManagedObject.BuiltInProperty(rawValue: key) == nil, - let _ = AttributeType(attributeType: attribute.attributeType) else { + let _ = AttributeType(attribute: attribute) else { continue } let property = PropertyKey(rawValue: key) diff --git a/Sources/CoreDataModel/NSManagedObjectModel.swift b/Sources/CoreDataModel/NSManagedObjectModel.swift index 0f24b58..ff605f8 100644 --- a/Sources/CoreDataModel/NSManagedObjectModel.swift +++ b/Sources/CoreDataModel/NSManagedObjectModel.swift @@ -12,10 +12,25 @@ import CoreModel public extension NSManagedObjectModel { - convenience init(model: Model) { + /// - Precondition: A model containing composite attributes may only back an + /// `NSSQLiteStoreType` store. Adding an atomic store (in-memory, XML, or binary) + /// for such a model raises an `NSInvalidArgumentException` — "Core Data provided + /// atomic stores do not support composite attributes" — which is an Objective-C + /// exception and therefore cannot be caught from Swift. + /// + /// - Throws: ``CoreDataModelError/compositeAttributesUnavailable(_:_:)`` when the + /// model declares a composite attribute but the platform is older than + /// macOS 14 / iOS 17 / tvOS 17 / watchOS 10, and + /// ``CoreDataModelError/emptyCompositeAttribute(_:)`` for a composite with no elements. + convenience init(model: Model) throws { self.init() // create entities - self.entities = model.entities.map { NSEntityDescription(entity: $0) } + var entities = [NSEntityDescription]() + entities.reserveCapacity(model.entities.count) + for entity in model.entities { + entities.append(try NSEntityDescription(entity: entity)) + } + self.entities = entities // set inverse relationships for entity in model.entities { guard let entityDescription = self.entitiesByName[entity.id.rawValue] else { diff --git a/Sources/CoreDataModel/NSPersistentContainer.swift b/Sources/CoreDataModel/NSPersistentContainer.swift index e8613c0..00f405d 100644 --- a/Sources/CoreDataModel/NSPersistentContainer.swift +++ b/Sources/CoreDataModel/NSPersistentContainer.swift @@ -80,12 +80,14 @@ public actor PersistentContainerStorage: ModelStorage, ObservableObject { // MARK: Initialization + /// - Throws: ``CoreDataModelError`` when the model cannot be represented in CoreData, + /// e.g. a composite attribute on a platform older than macOS 14 / iOS 17. public init( name: String, model: Model, storeDescriptions: [NSPersistentStoreDescription] = [] - ) { - let managedObjectModel = NSManagedObjectModel(model: model) + ) throws { + let managedObjectModel = try NSManagedObjectModel(model: model) let persistentContainer = NSPersistentContainer( name: name, managedObjectModel: managedObjectModel diff --git a/Sources/CoreDataModel/NSPredicate.swift b/Sources/CoreDataModel/NSPredicate.swift index ab46565..c721527 100644 --- a/Sources/CoreDataModel/NSPredicate.swift +++ b/Sources/CoreDataModel/NSPredicate.swift @@ -150,10 +150,27 @@ internal extension AttributeValue { case let .double(value): return value as NSNumber case let .url(value): return value as NSURL case let .decimal(value): return value as NSDecimalNumber + case let .composite(value): return value.toFoundationDictionary() } } } +internal extension Dictionary where Key == PropertyKey, Value == AttributeValue { + + /// The `NSDictionary` CoreData stores for a composite attribute. + /// + /// - Note: `.null` elements are omitted rather than stored as `NSNull`, matching + /// CoreData's requirement that every element of a composite be optional. + func toFoundationDictionary() -> NSDictionary { + let result = NSMutableDictionary(capacity: count) + for (key, value) in self { + guard let object = value.toFoundation() else { continue } + result[key.rawValue as NSString] = object + } + return result + } +} + internal extension RelationshipValue { func toFoundation() -> AnyObject? { diff --git a/Sources/CoreModel/Attribute.swift b/Sources/CoreModel/Attribute.swift index 1f18622..f13e878 100644 --- a/Sources/CoreModel/Attribute.swift +++ b/Sources/CoreModel/Attribute.swift @@ -21,6 +21,31 @@ public struct Attribute: Property, Equatable, Hashable, Identifiable, Sendable { } } +// MARK: - Composite + +public extension Attribute { + + /// Creates a composite attribute from its elements. + init(id: PropertyKey, elements: [Attribute]) { + self.init(id: id, type: .composite(elements)) + } + + /// Creates a composite attribute from a type that describes its own elements. + init(id: PropertyKey, composite type: T.Type) where T: CompositeAttribute { + self.init(id: id, type: T.attributeType) + } + + /// The elements of this attribute, if it is composite. + var elements: [Attribute]? { + type.elements + } + + /// The element with the given name, if this attribute is composite. + subscript(element id: PropertyKey) -> Attribute? { + type.elements?.first { $0.id == id } + } +} + // MARK: - Codable #if !hasFeature(Embedded) diff --git a/Sources/CoreModel/AttributeType.swift b/Sources/CoreModel/AttributeType.swift index a4e45d9..21f88db 100644 --- a/Sources/CoreModel/AttributeType.swift +++ b/Sources/CoreModel/AttributeType.swift @@ -6,47 +6,206 @@ // /// CoreModel Attribute type -public enum AttributeType: String, CaseIterable, Sendable { - +/// +/// - Note: This type was `RawRepresentable` by `String` and `CaseIterable` before +/// ``AttributeType/composite(_:)`` was introduced. Neither conformance survives an +/// associated value, so use ``scalarRawValue``, ``init(scalarRawValue:)``, and +/// ``scalarCases`` instead. +public enum AttributeType: Hashable, Sendable { + /// Boolean number type. case bool - + /// 16 bit Integer number type. case int16 - + /// Integer number type. case int32 - + /// Integer number type. case int64 - + /// Floating point number type. case float - + /// Floating point number type. case double - + /// Attribute is a string. case string - + /// Attribute is binary data. case data - + /// Attribute is a date. case date - + /// UUID case uuid - + /// URL case url - + /// Decimal case decimal + + /// An attribute whose value is a dictionary of named sub-attributes. + /// + /// Modeled on CoreData's `NSCompositeAttributeDescription`. The corresponding + /// value is ``AttributeValue/composite(_:)``, keyed by the ``Attribute/id`` of + /// each element. + /// + /// - Note: Elements are ``Attribute`` values, so an element can never be a + /// relationship, and may itself be `.composite`. Recursion is structurally + /// impossible: ``AttributeType`` is a value type, so no finite value contains itself. + case composite([Attribute]) +} + +// MARK: - Scalar Types + +public extension AttributeType { + + /// The string identifier for scalar attribute types, and `nil` for ``AttributeType/composite(_:)``. + /// + /// Replaces the `RawRepresentable` conformance this type had before composite + /// attributes. The identifiers are unchanged, so persisted schemas stay readable. + var scalarRawValue: String? { + switch self { + case .bool: return "bool" + case .int16: return "int16" + case .int32: return "int32" + case .int64: return "int64" + case .float: return "float" + case .double: return "double" + case .string: return "string" + case .data: return "data" + case .date: return "date" + case .uuid: return "uuid" + case .url: return "url" + case .decimal: return "decimal" + case .composite: return nil + } + } + + /// Creates a scalar attribute type from its string identifier. + /// + /// - Returns: `nil` for unrecognized identifiers, and for `"composite"`, whose + /// elements cannot be recovered from a string. + init?(scalarRawValue: String) { + switch scalarRawValue { + case "bool": self = .bool + case "int16": self = .int16 + case "int32": self = .int32 + case "int64": self = .int64 + case "float": self = .float + case "double": self = .double + case "string": self = .string + case "data": self = .data + case "date": self = .date + case "uuid": self = .uuid + case "url": self = .url + case "decimal": self = .decimal + default: return nil + } + } + + /// Every scalar (non-composite) attribute type. + /// + /// Replaces the `CaseIterable` conformance, which composite attributes make + /// impossible, since there are infinitely many composite types. + static var scalarCases: [AttributeType] { + [ + .bool, + .int16, + .int32, + .int64, + .float, + .double, + .string, + .data, + .date, + .uuid, + .url, + .decimal + ] + } + + /// The elements of a composite attribute, and `nil` for scalar types. + var elements: [Attribute]? { + guard case let .composite(elements) = self else { + return nil + } + return elements + } + + /// Whether this is a composite attribute type. + var isComposite: Bool { + guard case .composite = self else { + return false + } + return true + } +} + +// MARK: - CustomStringConvertible + +extension AttributeType: CustomStringConvertible { + + public var description: String { + guard case let .composite(elements) = self else { + return scalarRawValue ?? "" + } + let body = elements.reduce("", { + $0 + ($0.isEmpty ? "" : ", ") + $1.id.rawValue + ": " + $1.type.description + }) + return "composite(" + body + ")" + } } // MARK: - Codable #if !hasFeature(Embedded) -extension AttributeType: Codable {} +extension AttributeType: Codable { + + internal enum CodingKeys: String, CodingKey { + + case composite + } + + public init(from decoder: Decoder) throws { + // Scalar types encode as a plain string, exactly as they did before composite + // attributes existed, so previously persisted models still decode. + if let container = try? decoder.singleValueContainer(), + let rawValue = try? container.decode(String.self) { + guard let scalar = AttributeType(scalarRawValue: rawValue) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid attribute type \(rawValue)" + ) + ) + } + self = scalar + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let elements = try container.decode([Attribute].self, forKey: .composite) + self = .composite(elements) + } + + public func encode(to encoder: Encoder) throws { + switch self { + case let .composite(elements): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(elements, forKey: .composite) + case .bool, .int16, .int32, .int64, .float, .double, + .string, .data, .date, .uuid, .url, .decimal: + // - Note: The scalar cases are listed explicitly rather than with `default`, + // so that adding a future case is a compile error here. + assert(scalarRawValue != nil) + var container = encoder.singleValueContainer() + try container.encode(scalarRawValue ?? "") + } + } +} #endif diff --git a/Sources/CoreModel/Codable.swift b/Sources/CoreModel/Codable.swift index 7821b91..ad6a64e 100644 --- a/Sources/CoreModel/Codable.swift +++ b/Sources/CoreModel/Codable.swift @@ -6,3 +6,5 @@ // public typealias AttributeCodable = AttributeEncodable & AttributeDecodable + +public typealias CompositeAttributeCodable = CompositeAttributeEncodable & CompositeAttributeDecodable diff --git a/Sources/CoreModel/Decodable.swift b/Sources/CoreModel/Decodable.swift index f90d20c..19ca47e 100644 --- a/Sources/CoreModel/Decodable.swift +++ b/Sources/CoreModel/Decodable.swift @@ -99,13 +99,56 @@ public extension ModelData { } } +// MARK: - Composite Attribute Value Decoding + +public extension Dictionary where Key == PropertyKey, Value == AttributeValue { + + /// Decode an element of a composite attribute value. + /// + /// - Returns: `nil` when the element is absent, `.null`, or holds a value of a + /// different type — the shape ``CompositeAttributeDecodable/init(compositeValue:)`` + /// needs, since it is failable rather than throwing. + func decode(_ type: T.Type, forKey key: K) -> T? where T: AttributeDecodable, K: CodingKey { + + let property = PropertyKey(key) + guard let value = self[property] else { + return nil + } + return T.init(attributeValue: value) + } +} + // MARK: - AttributeDecodable public protocol AttributeDecodable { - + init?(attributeValue: AttributeValue) } +// MARK: - CompositeAttributeDecodable + +/// A type that can be loaded from the value of a composite attribute. +/// +/// - Note: A type that is both `CompositeAttributeDecodable` and `RawRepresentable` +/// where `RawValue: AttributeDecodable` inherits two candidate default implementations +/// of `init(attributeValue:)` and will not compile. Implement `init(attributeValue:)` +/// explicitly in that case. +public protocol CompositeAttributeDecodable: CompositeAttribute, AttributeDecodable { + + /// Initialize from the value of each element, keyed by its ``Attribute/id``. + init?(compositeValue: [PropertyKey: AttributeValue]) +} + +public extension CompositeAttributeDecodable { + + init?(attributeValue: AttributeValue) { + guard case let .composite(elements) = attributeValue else { + return nil + } + self.init(compositeValue: elements) + } +} + extension Optional: AttributeDecodable where Wrapped: AttributeDecodable { public init?(attributeValue: AttributeValue) { @@ -234,7 +277,8 @@ extension Int: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -259,7 +303,8 @@ extension Int8: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -284,7 +329,8 @@ extension Int16: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = value @@ -309,7 +355,8 @@ extension Int32: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -334,7 +381,8 @@ extension Int64: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -359,7 +407,8 @@ extension UInt: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -385,7 +434,8 @@ extension UInt8: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -411,7 +461,8 @@ extension UInt16: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -437,7 +488,8 @@ extension UInt32: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) @@ -462,7 +514,8 @@ extension UInt64: AttributeDecodable { .bool, .decimal, .float, - .double: + .double, + .composite: return nil case let .int16(value): self = numericCast(value) diff --git a/Sources/CoreModel/Encodable.swift b/Sources/CoreModel/Encodable.swift index 6a629cf..82ea0cd 100644 --- a/Sources/CoreModel/Encodable.swift +++ b/Sources/CoreModel/Encodable.swift @@ -47,13 +47,61 @@ public extension ModelData { } } +// MARK: - Composite Attribute Value Encoding + +public extension Dictionary where Key == PropertyKey, Value == AttributeValue { + + /// Encode an element of a composite attribute value. + mutating func encode(_ value: T, forKey key: K) where T: AttributeEncodable, K: CodingKey { + + let property = PropertyKey(key) + self[property] = value.attributeValue + } +} + // MARK: - AttributeEncodable public protocol AttributeEncodable { - + var attributeValue: AttributeValue { get } } +// MARK: - CompositeAttribute + +/// A type that describes the elements of the composite attribute it represents. +/// +/// Modeled on CoreData's `NSCompositeAttributeDescription`. Conform to +/// ``CompositeAttributeEncodable``, ``CompositeAttributeDecodable``, or +/// ``CompositeAttributeCodable`` rather than to this protocol directly. +public protocol CompositeAttribute { + + /// The named sub-attributes this composite is made of. + /// + /// - Note: Elements are ``Attribute`` values and so can never be relationships. + /// An element may itself be ``AttributeType/composite(_:)``. + static var attributeElements: [Attribute] { get } +} + +public extension CompositeAttribute { + + /// The attribute type describing this composite. + static var attributeType: AttributeType { .composite(attributeElements) } +} + +// MARK: - CompositeAttributeEncodable + +/// A type that can be stored as the value of a composite attribute. +public protocol CompositeAttributeEncodable: CompositeAttribute, AttributeEncodable { + + /// The value of each element, keyed by its ``Attribute/id``. + var compositeValue: [PropertyKey: AttributeValue] { get } +} + +public extension CompositeAttributeEncodable { + + var attributeValue: AttributeValue { .composite(compositeValue) } +} + extension Optional: AttributeEncodable where Wrapped: AttributeEncodable { public var attributeValue: AttributeValue { diff --git a/Sources/CoreModel/EntityDescription.swift b/Sources/CoreModel/EntityDescription.swift index 86c82b2..8b39b8c 100644 --- a/Sources/CoreModel/EntityDescription.swift +++ b/Sources/CoreModel/EntityDescription.swift @@ -21,6 +21,41 @@ public struct EntityDescription: Identifiable, Hashable, Sendable { } } +// MARK: - Property Resolution + +public extension EntityDescription { + + /// The attribute with the given name. + subscript(attribute id: PropertyKey) -> Attribute? { + attributes.first { $0.id == id } + } + + /// The relationship with the given name. + subscript(relationship id: PropertyKey) -> Relationship? { + relationships.first { $0.id == id } + } + + /// Resolve a key path to the attribute it addresses, descending through the + /// elements of composite attributes. + /// + /// - Returns: `nil` for paths that leave the attribute graph — a relationship + /// component, an index or operator component, or a name that no element declares. + func attribute(for keyPath: PredicateKeyPath) -> Attribute? { + guard case let .property(first)? = keyPath.keys.first, + var current = self[attribute: PropertyKey(rawValue: first)] else { + return nil + } + for key in keyPath.keys.dropFirst() { + guard case let .property(name) = key, + let next = current[element: PropertyKey(rawValue: name)] else { + return nil + } + current = next + } + return current + } +} + // MARK: - Codable #if !hasFeature(Embedded) diff --git a/Sources/CoreModel/FetchRequestEvaluation.swift b/Sources/CoreModel/FetchRequestEvaluation.swift index 0c17003..52f5107 100644 --- a/Sources/CoreModel/FetchRequestEvaluation.swift +++ b/Sources/CoreModel/FetchRequestEvaluation.swift @@ -82,8 +82,8 @@ public extension Array where Element == ModelData { let rhs: AttributeValue? switch descriptor.term { case let .property(property): - lhs = first.attributes[property] - rhs = second.attributes[property] + lhs = first.attributeValue(for: property) + rhs = second.attributeValue(for: property) case let .function(function): let expression = FetchRequest.Predicate.Expression.function(function) lhs = expression.evaluate(with: first, functions: functions)?.attributeValue diff --git a/Sources/CoreModel/InMemoryStorage.swift b/Sources/CoreModel/InMemoryStorage.swift index 8a9830b..aeef9ec 100644 --- a/Sources/CoreModel/InMemoryStorage.swift +++ b/Sources/CoreModel/InMemoryStorage.swift @@ -102,8 +102,17 @@ internal final class InMemoryStorage { ) -> ModelData { guard let description = model[entity] else { return value } var value = value - for attribute in description.attributes where value.attributes[attribute.id] == nil { - value.attributes[attribute.id] = .null + for attribute in description.attributes { + guard let existing = value.attributes[attribute.id] else { + value.attributes[attribute.id] = .null + continue + } + // A composite the caller only partly specified is filled out the same way, + // one level down, so that a declared element decodes as the absence it + // represents rather than as a missing key. An absent composite stays `.null` + // rather than becoming a dictionary of nulls. + guard case let .composite(elements) = attribute.type else { continue } + value.attributes[attribute.id] = existing.normalized(for: elements) } for relationship in description.relationships where relationship.type == .toMany { guard let destination = model[relationship.destinationEntity], @@ -162,6 +171,12 @@ internal final class InMemoryStorage { // touch every relationship (e.g. a site catalog refresh that never re-states // `parkingReservations`, which is written by an entirely separate sync) would // silently wipe those links instead of leaving them alone. + // + // This merge is per-property only: a composite attribute is replaced whole + // rather than merged element-wise, matching Core Data's composite setter and + // backends that store the composite as a single column or sub-document. Since + // `normalized` makes every read well formed, a fetch/modify/insert round trip + // still never loses elements. if var existing = state.objects[value.entity]?[value.id] { // - Note: Explicit loops rather than `Dictionary.merge(_:uniquingKeysWith:)` — // the closure-based overload does dynamic casting internally, which is diff --git a/Sources/CoreModel/Macros.swift b/Sources/CoreModel/Macros.swift index 94500f4..758eff8 100644 --- a/Sources/CoreModel/Macros.swift +++ b/Sources/CoreModel/Macros.swift @@ -19,6 +19,21 @@ public macro Attribute(_ type: AttributeType? = nil) = #externalMacro( type: "AttributeMacro" ) +/// Declares a property as a composite attribute. +/// +/// The property's type must conform to ``CompositeAttribute``; its +/// ``CompositeAttribute/attributeElements`` supply the element list. +/// +/// ```swift +/// @CompositeAttribute +/// var location: LocationCoordinates +/// ``` +@attached(peer) +public macro CompositeAttribute() = #externalMacro( + module: "CoreModelMacros", + type: "CompositeAttributeMacro" +) + @attached(peer) public macro Relationship(destination: T.Type, inverse: T.CodingKeys) = #externalMacro( module: "CoreModelMacros", diff --git a/Sources/CoreModel/Predicate/Evaluate.swift b/Sources/CoreModel/Predicate/Evaluate.swift index 939562b..6597677 100644 --- a/Sources/CoreModel/Predicate/Evaluate.swift +++ b/Sources/CoreModel/Predicate/Evaluate.swift @@ -88,6 +88,55 @@ internal extension PredicateValue { return value } + /// The values traversed through a to-many relationship, if any. + var aggregateValues: [PredicateValue]? { + guard case let .aggregate(values) = self else { return nil } + return values + } + + /// Resolve a key path against an object, descending through composite attribute values. + /// + /// The first component names a property of the object itself, and each further + /// component names an element of the composite value the previous one resolved to. + /// Descent stays inside attributes; a key path whose first component is a + /// *relationship* is resolved by ``PredicateKeyPath/traverse(_:functions:objects:)`` + /// instead, which needs the related objects. + /// + /// A property whose name literally contains a dot is matched before the path is + /// traversed, so every model that predates composite attributes resolves as before. + /// + /// - Returns: `nil` for an empty key path, for `.index`/`.operator` components, + /// and for any name that doesn't resolve. + init?(data: ModelData, keyPath: PredicateKeyPath) { + guard case let .property(name)? = keyPath.keys.first else { + return nil + } + let literal = PropertyKey(rawValue: keyPath.rawValue) + if let attribute = data.attributes[literal] { + self = .attribute(attribute) + return + } + if let relationship = data.relationships[literal] { + self = .relationship(relationship) + return + } + guard keyPath.keys.count > 1 else { + return nil + } + guard var current = data.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 + } + self = .attribute(current) + } + /// An object identifier this value can represent, for relationship comparisons. var objectIDValue: ObjectID? { switch self { @@ -103,6 +152,24 @@ internal extension PredicateValue { } } +// MARK: - Property Resolution + +internal extension ModelData { + + /// The attribute value a property name addresses, which may be a dotted path into a + /// composite attribute (e.g. `location.latitude`). + /// + /// A direct lookup wins, so an attribute whose name literally contains a dot still + /// resolves as it did before composite attributes existed. + func attributeValue(for property: PropertyKey) -> AttributeValue? { + // fast path for the overwhelmingly common single-component name + if let value = attributes[property] { + return value + } + return PredicateValue(data: self, keyPath: PredicateKeyPath(rawValue: property.rawValue))?.attributeValue + } +} + // MARK: - Expression Evaluation internal extension FetchRequest.Predicate.Expression { @@ -119,12 +186,10 @@ internal extension FetchRequest.Predicate.Expression { case let .relationship(value): return .relationship(value) case let .keyPath(keyPath): - let key = PropertyKey(rawValue: keyPath.rawValue) - if let attribute = data.attributes[key] { - return .attribute(attribute) - } - if let relationship = data.relationships[key] { - return .relationship(relationship) + // matches the literal property name, then descends through composite + // attribute elements (e.g. `location.latitude`) + if let value = PredicateValue(data: data, keyPath: keyPath) { + return value } // a key path like `events.name` traverses a relationship to related objects return keyPath.traverse(data, functions: functions, objects: objects) diff --git a/Sources/CoreModel/Predicate/Expression.swift b/Sources/CoreModel/Predicate/Expression.swift index a99c0f2..5819ffd 100644 --- a/Sources/CoreModel/Predicate/Expression.swift +++ b/Sources/CoreModel/Predicate/Expression.swift @@ -98,6 +98,15 @@ internal extension AttributeValue { case let .double(value): return value.description case let .url(value): return value.description case let .decimal(value): return value.description + case let .composite(value): + // - Note: Sorted by element name so that diagnostics are deterministic, + // since `Dictionary` iteration order is not. + let body = value + .sorted { $0.key.rawValue < $1.key.rawValue } + .reduce("", { + $0 + ($0.isEmpty ? "" : ", ") + $1.key.rawValue + " = " + $1.value.predicateDescription + }) + return "{" + body + "}" } } } diff --git a/Sources/CoreModel/PropertyValue.swift b/Sources/CoreModel/PropertyValue.swift index 1244aa8..3534e53 100644 --- a/Sources/CoreModel/PropertyValue.swift +++ b/Sources/CoreModel/PropertyValue.swift @@ -29,6 +29,46 @@ public enum AttributeValue: Equatable, Hashable, Sendable { case float(Float) case double(Double) case decimal(Decimal) + + /// The value of a composite attribute, keyed by the names of the ``Attribute`` + /// elements its ``AttributeType/composite(_:)`` declares. + /// + /// - Note: `.null` — not a dictionary of `.null` elements — is the canonical + /// representation of an absent composite attribute. + case composite([PropertyKey: AttributeValue]) +} + +// MARK: - Composite Normalization + +public extension AttributeValue { + + /// Fill in the elements a composite value doesn't specify with `.null`, recursively. + /// + /// `.null` — what an absent composite attribute normalizes to — is returned + /// unchanged: an absent composite is absent as a whole, not a dictionary of absent + /// elements. Elements not declared by `elements` are preserved, matching how + /// undeclared top-level attributes are treated. + /// + /// Storage backends should apply this when reading, so that a declared element is + /// never missing from a composite that is itself present. + func normalized(for elements: [Attribute]) -> AttributeValue { + guard case let .composite(values) = self else { + return self + } + // - Note: Explicit loop rather than `Dictionary.merge(_:uniquingKeysWith:)` — + // the closure-based overload does dynamic casting internally, which is + // disallowed under Embedded Swift. + var normalized = values + for element in elements { + guard let value = normalized[element.id] else { + normalized[element.id] = .null + continue + } + guard case let .composite(nested) = element.type else { continue } + normalized[element.id] = value.normalized(for: nested) + } + return .composite(normalized) + } } // MARK: - Relationship diff --git a/Sources/CoreModel/SortDescriptor.swift b/Sources/CoreModel/SortDescriptor.swift index 605c3bd..648e7cb 100644 --- a/Sources/CoreModel/SortDescriptor.swift +++ b/Sources/CoreModel/SortDescriptor.swift @@ -29,6 +29,11 @@ public extension FetchRequest { /// with the underlying store via ``DatabaseFunction``). enum SortTerm: Equatable, Hashable, Sendable { + /// Sort by a property of the object. + /// + /// A dotted name is a key path into the elements of a composite attribute + /// (e.g. `officeHours.start`), matching `NSSortDescriptor(key:ascending:)`. + /// A property whose name literally contains a dot still takes precedence. case property(PropertyKey) case function(Predicate.FunctionExpression) } @@ -88,8 +93,8 @@ extension FetchRequest.SortDescriptor: SortComparator { let rhsValue: AttributeValue? switch term { case let .property(property): - lhsValue = lhs.attributes[property] - rhsValue = rhs.attributes[property] + lhsValue = lhs.attributeValue(for: property) + rhsValue = rhs.attributeValue(for: property) case let .function(function): let expression = FetchRequest.Predicate.Expression.function(function) lhsValue = expression.evaluate(with: lhs, functions: [:])?.attributeValue diff --git a/Sources/CoreModelMacros/Attribute.swift b/Sources/CoreModelMacros/Attribute.swift index 6bac191..969e1dc 100644 --- a/Sources/CoreModelMacros/Attribute.swift +++ b/Sources/CoreModelMacros/Attribute.swift @@ -22,6 +22,18 @@ public struct AttributeMacro: PeerMacro { } } +public struct CompositeAttributeMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + // Tag only, logic handled in EntityMacro + return [] + } +} + internal func inferAttributeType(from type: String) -> String? { switch type { case "String": return ".string" diff --git a/Sources/CoreModelMacros/Entity.swift b/Sources/CoreModelMacros/Entity.swift index 3d7b21a..751ec6a 100644 --- a/Sources/CoreModelMacros/Entity.swift +++ b/Sources/CoreModelMacros/Entity.swift @@ -158,7 +158,8 @@ extension EntityMacro { let inferredType = inferAttributeType(from: typeName) for attr in attributes.compactMap({ $0.as(AttributeSyntax.self) }) { - if attr.attributeName.description == "Attribute" { + switch attr.attributeName.trimmedDescription { + case "Attribute": let type: String if let argument = attr.arguments?.description.trimmingCharacters(in: .whitespacesAndNewlines) { // Use explicit parameter @@ -170,6 +171,18 @@ extension EntityMacro { throw MacroError.unknownAttributeType(for: identifier) } attributeEntries.append(".\(identifier): \(type)") + case "CompositeAttribute": + // The element list comes from the declared type, so no arguments are accepted. + guard attr.arguments == nil else { + throw MacroError.invalidCompositeAttribute(for: identifier) + } + // `typeName` already has any Optional wrapper stripped. + guard typeName.hasPrefix("[") == false, typeName.hasSuffix("]") == false else { + throw MacroError.invalidCompositeAttribute(for: identifier) + } + attributeEntries.append(".\(identifier): \(typeName).attributeType") + default: + continue } } } @@ -283,8 +296,8 @@ extension EntityMacro { else { continue } let rawTypeName = typeSyntax.description.trimmingCharacters(in: .whitespacesAndNewlines) for attr in varDecl.attributes.compactMap({ $0.as(AttributeSyntax.self) }) { - switch attr.attributeName.description { - case "Attribute": + switch attr.attributeName.trimmedDescription { + case "Attribute", "CompositeAttribute": properties.append((identifier, rawTypeName, false)) case "Relationship": properties.append((identifier, rawTypeName, true)) diff --git a/Sources/CoreModelMacros/Error.swift b/Sources/CoreModelMacros/Error.swift index bcce9da..ceb13c0 100644 --- a/Sources/CoreModelMacros/Error.swift +++ b/Sources/CoreModelMacros/Error.swift @@ -17,6 +17,9 @@ enum MacroError: Error { /// Unknown inverse relationship case unknownInverseRelationship(for: String) + + /// `@CompositeAttribute` was supplied arguments, or applied to a collection type. + case invalidCompositeAttribute(for: String) } #if canImport(Darwin) @@ -30,6 +33,8 @@ extension MacroError: LocalizedError { return String(format: NSLocalizedString("Unknown attribute type: %@", comment: "Unknown attribute type error"), type) case .unknownInverseRelationship(let relationship): return String(format: NSLocalizedString("Unknown inverse relationship for: %@", comment: "Unknown inverse relationship error"), relationship) + case .invalidCompositeAttribute(let property): + return String(format: NSLocalizedString("Invalid composite attribute: %@. @CompositeAttribute takes no arguments and requires a type conforming to CompositeAttribute.", comment: "Invalid composite attribute error"), property) } } } diff --git a/Sources/CoreModelMacros/Macros.swift b/Sources/CoreModelMacros/Macros.swift index ace1960..39081ba 100644 --- a/Sources/CoreModelMacros/Macros.swift +++ b/Sources/CoreModelMacros/Macros.swift @@ -15,6 +15,7 @@ struct Plugins: CompilerPlugin { let providingMacros: [Macro.Type] = [ EntityMacro.self, RelationshipMacro.self, - AttributeMacro.self + AttributeMacro.self, + CompositeAttributeMacro.self ] } diff --git a/Tests/CoreModelMacrosTests/EntityMacroTests.swift b/Tests/CoreModelMacrosTests/EntityMacroTests.swift index 0aed73c..81e2d8b 100644 --- a/Tests/CoreModelMacrosTests/EntityMacroTests.swift +++ b/Tests/CoreModelMacrosTests/EntityMacroTests.swift @@ -295,17 +295,283 @@ import SwiftSyntaxMacroExpansion let context = BasicMacroExpansionContext() #expect(try AttributeMacro.expansion(of: node, providingPeersOf: declaration, in: context).count == 0) #expect(try RelationshipMacro.expansion(of: node, providingPeersOf: declaration, in: context).count == 0) + #expect(try CompositeAttributeMacro.expansion(of: node, providingPeersOf: declaration, in: context).count == 0) } @Test func expansionNames() { #expect(EntityMacro.expansionNames.count == 5) } + // MARK: - Composite Attributes + + @Test func compositeAttribute() throws { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location: LocationCoordinates + } + """) + let context = BasicMacroExpansionContext() + let source = try expandMembers(of: node, attachedTo: declaration, in: context) + .map { $0.description }.joined(separator: "\n") + #expect(source.contains(".location: LocationCoordinates.attributeType")) + // encode/decode codegen is unchanged + #expect(source.contains("container.decode(LocationCoordinates.self, forKey: Campground.CodingKeys.location)")) + #expect(source.contains("container.encode(self.location, forKey: Campground.CodingKeys.location)")) + } + + @Test func optionalCompositeAttribute() throws { + for declared in ["LocationCoordinates?", "Optional"] { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location: \(declared) + } + """) + let context = BasicMacroExpansionContext() + let source = try expandMembers(of: node, attachedTo: declaration, in: context) + .map { $0.description }.joined(separator: "\n") + // the Optional wrapper is stripped for the element list ... + #expect(source.contains(".location: LocationCoordinates.attributeType")) + // ... but kept for decoding, which routes through the Optional conformance + #expect(source.contains("container.decode(\(declared).self, forKey: Campground.CodingKeys.location)")) + } + } + + @Test func nestedCompositeTypeName() throws { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location: Campground.LocationCoordinates + } + """) + let context = BasicMacroExpansionContext() + let source = try expandMembers(of: node, attachedTo: declaration, in: context) + .map { $0.description }.joined(separator: "\n") + #expect(source.contains(".location: Campground.LocationCoordinates.attributeType")) + } + + /// `@Attribute` with an explicit composite type works with no macro support, + /// since the argument is passed through verbatim. + @Test func explicitCompositeAttributeType() throws { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @Attribute(LocationCoordinates.attributeType) + var location: LocationCoordinates + } + """) + let context = BasicMacroExpansionContext() + let source = try expandMembers(of: node, attachedTo: declaration, in: context) + .map { $0.description }.joined(separator: "\n") + #expect(source.contains(".location: LocationCoordinates.attributeType")) + } + + @Test func invalidCompositeAttribute() throws { + // a collection type cannot be a composite + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var tags: [Tag] + } + """) + let context = BasicMacroExpansionContext() + #expect(throws: MacroError.self) { + try expandMembers(of: node, attachedTo: declaration, in: context) + } + // arguments are not accepted + let (node2, declaration2) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute(.string) + var location: LocationCoordinates + } + """) + #expect(throws: MacroError.self) { + try expandMembers(of: node2, attachedTo: declaration2, in: BasicMacroExpansionContext()) + } + } + + @Test func compositeCodableProperties() throws { + let (_, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location: LocationCoordinates + } + """) + let properties = EntityMacro.codableProperties(of: declaration) + #expect(properties.count == 1) + #expect(properties[0].name == "location") + #expect(properties[0].isRelationship == false) + } + + /// An entity mixing scalar attributes, required and optional composites, and a + /// relationship generates all five members consistently. + @Test func mixedEntityExpansion() throws { + let (node, declaration) = try parse(""" + @Entity("Campground") + struct Campground { + var id: UUID + @Attribute + var name: String + @CompositeAttribute + var location: LocationCoordinates + @CompositeAttribute + var officeHours: Schedule? + @Relationship(destination: Unit.self, inverse: .campground) + var units: [Unit.ID] + } + """) + let context = BasicMacroExpansionContext() + let members = try expandMembers(of: node, attachedTo: declaration, in: context) + #expect(members.count == 5) + let source = members.map { $0.description }.joined(separator: "\n") + #expect(source.contains(#"public static var entityName: EntityName { "Campground" }"#)) + // scalar and composite attributes live side by side + #expect(source.contains(".name: .string")) + #expect(source.contains(".location: LocationCoordinates.attributeType")) + #expect(source.contains(".officeHours: Schedule.attributeType")) + // the relationship is not folded into `attributes` + #expect(source.contains(".units: .string") == false) + #expect(source.contains(".units: Unit.attributeType") == false) + #expect(source.contains(".units: Relationship(")) + #expect(source.contains("destination: Unit.self")) + // decode keeps the optional wrapper, encode passes the property through + #expect(source.contains("container.decode(LocationCoordinates.self, forKey: Campground.CodingKeys.location)")) + #expect(source.contains("container.decode(Schedule?.self, forKey: Campground.CodingKeys.officeHours)")) + #expect(source.contains("container.encode(self.location, forKey: Campground.CodingKeys.location)")) + #expect(source.contains("container.encodeRelationship(self.units, forKey: Campground.CodingKeys.units)")) + } + + @Test func multipleCompositeAttributes() throws { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location: LocationCoordinates + @CompositeAttribute + var officeHours: Schedule + } + """) + let context = BasicMacroExpansionContext() + let source = try expandMembers(of: node, attachedTo: declaration, in: context) + .map { $0.description }.joined(separator: "\n") + #expect(source.contains(".location: LocationCoordinates.attributeType")) + #expect(source.contains(".officeHours: Schedule.attributeType")) + // declaration order is preserved + let properties = EntityMacro.codableProperties(of: declaration) + #expect(properties.map { $0.name } == ["location", "officeHours"]) + } + + @Test func compositeAttributeInClass() throws { + let (node, declaration) = try parse(""" + @Entity + class Campground { + var id: UUID + @CompositeAttribute + var location: LocationCoordinates + } + """) + let context = BasicMacroExpansionContext() + let source = try expandMembers(of: node, attachedTo: declaration, in: context) + .map { $0.description }.joined(separator: "\n") + #expect(source.contains(".location: LocationCoordinates.attributeType")) + } + + @Test func compositeExtensionExpansion() throws { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location: LocationCoordinates + } + """) + let context = BasicMacroExpansionContext() + let extensions = try EntityMacro.expansion( + of: node, + attachedTo: declaration, + providingExtensionsOf: TypeSyntax(stringLiteral: "Campground"), + conformingTo: [], + in: context + ) + #expect(extensions.count == 1) + #expect(extensions[0].description.contains("CoreModel.Entity")) + } + + /// A property with no type annotation carries no type name to build elements from, + /// so it is skipped rather than crashing. + @Test func compositeAttributeWithoutTypeAnnotation() throws { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location = LocationCoordinates(latitude: 0, longitude: 0) + } + """) + let context = BasicMacroExpansionContext() + let source = try expandMembers(of: node, attachedTo: declaration, in: context) + .map { $0.description }.joined(separator: "\n") + #expect(source.contains("location") == false) + #expect(EntityMacro.codableProperties(of: declaration).isEmpty) + } + + @Test func compositeAttributeRejectsCollections() throws { + for declared in ["[LocationCoordinates]", "[String: LocationCoordinates]"] { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var values: \(declared) + } + """) + #expect(throws: MacroError.self) { + try expandMembers(of: node, attachedTo: declaration, in: BasicMacroExpansionContext()) + } + } + } + + /// `@Attribute` and `@CompositeAttribute` are the only attribute spellings that + /// contribute; an unrelated property wrapper is still ignored. + @Test func unrelatedAttributeIgnoredAlongsideComposite() throws { + let (node, declaration) = try parse(""" + @Entity + struct Campground { + var id: UUID + @CompositeAttribute + var location: LocationCoordinates + @Published + var ignored: Int + } + """) + let context = BasicMacroExpansionContext() + let properties = EntityMacro.codableProperties(of: declaration) + #expect(properties.map { $0.name } == ["location"]) + let initDecl = try EntityMacro.initDeclarationSyntax(of: node, providingMembersOf: declaration, in: context) + #expect(initDecl.description.contains("ignored") == false) + } + #if canImport(Darwin) @Test func macroErrorDescriptions() { #expect(MacroError.invalidType.errorDescription != nil) #expect(MacroError.unknownAttributeType(for: "point").errorDescription != nil) #expect(MacroError.unknownInverseRelationship(for: "pets").errorDescription != nil) + #expect(MacroError.invalidCompositeAttribute(for: "location").errorDescription != nil) } #endif } diff --git a/Tests/CoreModelTests/BatchInsertTests.swift b/Tests/CoreModelTests/BatchInsertTests.swift index e89e262..cf9c38d 100644 --- a/Tests/CoreModelTests/BatchInsertTests.swift +++ b/Tests/CoreModelTests/BatchInsertTests.swift @@ -20,7 +20,7 @@ struct BatchInsertTests { /// to-many relationships, duplicate values in the same batch, relationship targets /// appearing after the values that reference them, and an upsert pass over /// existing data. - @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) @Test func batchInsert() async throws { @@ -115,7 +115,7 @@ struct BatchInsertTests { /// batch cost ~100x more — because each fetch request evaluates its predicate /// against every pending unsaved object in the context. The prefetched object /// cache keeps the cost of a 10x larger batch at roughly 10x. - @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) @Test func batchInsertScaling() async throws { // warm up the Core Data stack setup costs @@ -128,7 +128,7 @@ struct BatchInsertTests { #expect(ratio < 40, "10x larger batch should cost roughly 10x, not \(ratio)x") } - @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) private func measureInsert(count: Int) async throws -> TimeInterval { let store = try await makeStore() let people = (0 ..< 20).map { @@ -148,7 +148,7 @@ struct BatchInsertTests { return Date().timeIntervalSince(start) } - @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) private func makeStore() async throws -> NSPersistentContainer { let model = Model( entities: @@ -157,7 +157,7 @@ struct BatchInsertTests { Campground.self, Campground.Unit.self ) - let managedObjectModel = NSManagedObjectModel(model: model) + let managedObjectModel = try NSManagedObjectModel(model: model) let store = NSPersistentContainer( name: "Test\(UUID())", managedObjectModel: managedObjectModel diff --git a/Tests/CoreModelTests/CompositeAttributeTests.swift b/Tests/CoreModelTests/CompositeAttributeTests.swift new file mode 100644 index 0000000..be4b9f1 --- /dev/null +++ b/Tests/CoreModelTests/CompositeAttributeTests.swift @@ -0,0 +1,272 @@ +// +// CompositeAttributeTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import Testing +@testable import CoreModel + +@Suite struct CompositeAttributeTests { + + enum Key: CodingKey { + case value + } + + static let coordinates = Campground.LocationCoordinates(latitude: 34.51446212994721, longitude: -89.15318142250365) + + static var coordinatesValue: AttributeValue { + .composite([ + "latitude": .double(34.51446212994721), + "longitude": .double(-89.15318142250365) + ]) + } + + // MARK: - Encoding + + @Test func encodeComposite() { + #expect(Self.coordinates.attributeValue == Self.coordinatesValue) + #expect(Campground.LocationCoordinates.attributeType == .composite([ + Attribute(id: "latitude", type: .double), + Attribute(id: "longitude", type: .double) + ])) + } + + @Test func decodeComposite() { + #expect(Campground.LocationCoordinates(attributeValue: Self.coordinatesValue) == Self.coordinates) + } + + @Test func decodeInvalidComposite() { + // not a composite at all + #expect(Campground.LocationCoordinates(attributeValue: .string("34.5,-89.1")) == nil) + #expect(Campground.LocationCoordinates(attributeValue: .null) == nil) + // element of the wrong type + #expect(Campground.LocationCoordinates(attributeValue: .composite([ + "latitude": .string("34.5"), + "longitude": .double(-89.1) + ])) == nil) + // missing element + #expect(Campground.LocationCoordinates(attributeValue: .composite([ + "latitude": .double(34.5) + ])) == nil) + // element explicitly null + #expect(Campground.LocationCoordinates(attributeValue: .composite([ + "latitude": .double(34.5), + "longitude": .null + ])) == nil) + } + + @Test func optionalComposite() { + let none: Campground.LocationCoordinates? = nil + #expect(none.attributeValue == .null) + #expect(Campground.LocationCoordinates?(attributeValue: .null) == .some(.none)) + let some: Campground.LocationCoordinates? = Self.coordinates + #expect(some.attributeValue == Self.coordinatesValue) + } + + @Test func nestedComposite() { + let address = Address(street: "1 Main", city: "Springfield", location: Self.coordinates) + let expected = AttributeValue.composite([ + "street": .string("1 Main"), + "city": .string("Springfield"), + "location": Self.coordinatesValue + ]) + #expect(address.attributeValue == expected) + #expect(Address(attributeValue: expected) == address) + // the element list nests too + #expect(Address.attributeType == .composite([ + Attribute(id: "street", type: .string), + Attribute(id: "city", type: .string), + Attribute(id: "location", type: Campground.LocationCoordinates.attributeType) + ])) + } + + // MARK: - ModelData + + @Test func modelDataRoundTrip() throws { + var data = ModelData(entity: "Test", id: "1") + data.encode(Self.coordinates, forKey: Key.value) + #expect(data.attributes[.init(Key.value)] == Self.coordinatesValue) + let decoded = try data.decode(Campground.LocationCoordinates.self, forKey: Key.value) + #expect(decoded == Self.coordinates) + } + + @Test func modelDataTypeMismatch() { + var data = ModelData(entity: "Test", id: "1") + data.encode("not a composite", forKey: Key.value) + #expect(throws: (any Error).self) { + try data.decode(Campground.LocationCoordinates.self, forKey: Key.value) + } + } + + /// `Schedule.init(start:end:)` asserts `start < end`, so decoding must assign the + /// stored properties directly rather than route through it. + @Test func decodeScheduleWithoutAssertion() { + let value = AttributeValue.composite(["start": .int64(20), "end": .int64(5)]) + let schedule = Campground.Schedule(attributeValue: value) + #expect(schedule?.start == 20) + #expect(schedule?.end == 5) + } + + // MARK: - Schema + + @Test func attributeElementLookup() { + let attribute = Attribute(id: "location", composite: Campground.LocationCoordinates.self) + #expect(attribute.elements?.count == 2) + #expect(attribute[element: "latitude"]?.type == .double) + #expect(attribute[element: "missing"] == nil) + #expect(attribute.type.isComposite) + // a scalar attribute has no elements + #expect(Attribute(id: "name", type: .string).elements == nil) + #expect(Attribute(id: "name", type: .string).type.isComposite == false) + } + + @Test func entityDescriptionPropertyLookup() { + let entity = EntityDescription(entity: Campground.self) + #expect(entity[attribute: "name"]?.type == .string) + #expect(entity[attribute: "location"]?.type == Campground.LocationCoordinates.attributeType) + // an attribute subscript never returns a relationship, and vice versa + #expect(entity[attribute: "units"] == nil) + #expect(entity[relationship: "units"]?.destinationEntity == "RentalUnit") + #expect(entity[relationship: "name"] == nil) + #expect(entity[attribute: "missing"] == nil) + #expect(entity[relationship: "missing"] == nil) + } + + @Test func entityDescriptionKeyPathResolution() { + let entity = EntityDescription(entity: Campground.self) + #expect(entity.attribute(for: "location")?.type == Campground.LocationCoordinates.attributeType) + #expect(entity.attribute(for: "location.latitude")?.type == .double) + #expect(entity.attribute(for: "officeHours.start")?.type == .int64) + // paths that leave the attribute graph + #expect(entity.attribute(for: "location.altitude") == nil) + #expect(entity.attribute(for: "name.nope") == nil) + #expect(entity.attribute(for: "units") == nil) + #expect(entity.attribute(for: PredicateKeyPath(keys: [])) == nil) + } + + @Test func scalarTypeBridge() { + for type in AttributeType.scalarCases { + let rawValue = try! #require(type.scalarRawValue) + #expect(AttributeType(scalarRawValue: rawValue) == type) + } + #expect(AttributeType.scalarCases.count == 12) + #expect(Campground.LocationCoordinates.attributeType.scalarRawValue == nil) + #expect(AttributeType(scalarRawValue: "composite") == nil) + #expect(AttributeType(scalarRawValue: "bogus") == nil) + } + + // MARK: - Normalization + + @Test func normalizeComposite() { + let elements = Campground.LocationCoordinates.attributeElements + // a partially specified composite gains the elements it doesn't declare + let partial = AttributeValue.composite(["latitude": .double(1)]) + #expect(partial.normalized(for: elements) == .composite([ + "latitude": .double(1), + "longitude": .null + ])) + // an absent composite stays absent, rather than becoming a dictionary of nulls + #expect(AttributeValue.null.normalized(for: elements) == .null) + // undeclared elements are preserved + let extra = AttributeValue.composite([ + "latitude": .double(1), + "longitude": .double(2), + "altitude": .double(3) + ]) + #expect(extra.normalized(for: elements) == extra) + } + + @Test func normalizeNestedComposite() { + let partial = AttributeValue.composite(["street": .string("1 Main")]) + #expect(partial.normalized(for: Address.attributeElements) == .composite([ + "street": .string("1 Main"), + "city": .null, + "location": .null + ])) + // a present-but-partial nested composite is filled in one level down + let nested = AttributeValue.composite([ + "street": .string("1 Main"), + "location": .composite(["latitude": .double(1)]) + ]) + #expect(nested.normalized(for: Address.attributeElements) == .composite([ + "street": .string("1 Main"), + "city": .null, + "location": .composite(["latitude": .double(1), "longitude": .null]) + ])) + } + + // MARK: - Comparison + + @Test func compositeComparison() { + let value = Self.coordinatesValue + // equality is recursive dictionary equality, and order independent + #expect(AttributeValue.areEqual(value, Self.coordinates.attributeValue, caseInsensitive: false)) + #expect(AttributeValue.areEqual(value, .composite(["latitude": .double(1)]), caseInsensitive: false) == false) + // composites are not orderable + #expect(AttributeValue.order(value, .composite(["latitude": .double(1)])) == nil) + // a composite of nulls is not itself null + let allNull = PredicateValue.attribute(.composite(["latitude": .null])) + #expect(allNull.isNull == false) + #expect(PredicateValue.attribute(.null).isNull) + } + + @Test func compositeDescription() { + #expect(Campground.LocationCoordinates.attributeType.description == "composite(latitude: double, longitude: double)") + #expect(AttributeType.string.description == "string") + // rendered sorted by element name, so diagnostics are deterministic + #expect(Self.coordinatesValue.predicateDescription == "{latitude = 34.51446212994721, longitude = -89.15318142250365}") + } + + // MARK: - Codable + + @Test func attributeTypeCodable() throws { + // scalars keep the pre-composite wire format + for type in AttributeType.scalarCases { + let data = try JSONEncoder().encode(type) + #expect(String(data: data, encoding: .utf8) == "\"\(type.scalarRawValue!)\"") + #expect(try JSONDecoder().decode(AttributeType.self, from: data) == type) + } + // composites, including nested ones, round trip + for type in [Campground.LocationCoordinates.attributeType, Address.attributeType] { + let data = try JSONEncoder().encode(type) + #expect(try JSONDecoder().decode(AttributeType.self, from: data) == type) + } + // an unknown identifier is a decoding error, not a silent default + #expect(throws: (any Error).self) { + try JSONDecoder().decode(AttributeType.self, from: Data(#""bogus""#.utf8)) + } + } + + /// An `Attribute` carrying a scalar type must encode exactly as it did before + /// composite attributes existed, so persisted models stay readable. + @Test func attributeWireCompatibility() throws { + let attribute = Attribute(id: "name", type: .string) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let json = String(data: try encoder.encode(attribute), encoding: .utf8) + #expect(json == #"{"id":"name","type":"string"}"#) + #expect(try JSONDecoder().decode(Attribute.self, from: try encoder.encode(attribute)) == attribute) + } + + @Test func modelCodable() throws { + let model = Model(entities: [ + EntityDescription(entity: Campground.self), + EntityDescription(entity: Campground.Unit.self) + ]) + let data = try JSONEncoder().encode(model) + #expect(try JSONDecoder().decode(Model.self, from: data) == model) + } + + @Test func attributeValueCodable() throws { + let value = AttributeValue.composite([ + "street": .string("1 Main"), + "location": Self.coordinatesValue, + "missing": .null + ]) + let data = try JSONEncoder().encode(value) + #expect(try JSONDecoder().decode(AttributeValue.self, from: data) == value) + } +} diff --git a/Tests/CoreModelTests/CompositeCoreDataTests.swift b/Tests/CoreModelTests/CompositeCoreDataTests.swift new file mode 100644 index 0000000..bb976a8 --- /dev/null +++ b/Tests/CoreModelTests/CompositeCoreDataTests.swift @@ -0,0 +1,282 @@ +// +// CompositeCoreDataTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +#if canImport(CoreData) + +import Foundation +import CoreData +import Testing +@testable import CoreModel +@testable import CoreDataModel + +/// Composite attributes through the CoreData bridge. +/// +/// - Note: CoreData supports composite attributes only in SQLite-backed stores, so +/// every store here is built with an explicit `NSSQLiteStoreType` description. +@Suite(.serialized) struct CompositeCoreDataTests { + + // MARK: - Schema + + @Test func attributeTypeConversion() throws { + let type = Campground.LocationCoordinates.attributeType + #expect(NSAttributeType(attributeType: type).rawValue == 2100) + #expect(NSAttributeType.composite.rawValue == 2100) + // the type alone can't carry elements, so this direction stays nil + #expect(AttributeType(attributeType: .composite) == nil) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func compositeAttributeDescription() throws { + let attribute = Attribute(id: "location", composite: Campground.LocationCoordinates.self) + let description = try NSAttributeDescription.make(attribute: attribute) + let composite = try #require(description as? NSCompositeAttributeDescription) + #expect(composite.name == "location") + #expect(composite.attributeType.rawValue == 2100) + #expect(composite.elements.count == 2) + #expect(composite.elements.map(\.name).sorted() == ["latitude", "longitude"]) + // every element must be optional, or saving raises a validation fault + #expect(composite.elements.allSatisfy { $0.isOptional }) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func nestedCompositeAttributeDescription() throws { + let elements = [ + Attribute(id: "street", type: .string), + Attribute(id: "location", composite: Campground.LocationCoordinates.self) + ] + let description = try NSAttributeDescription.make(attribute: Attribute(id: "address", elements: elements)) + let composite = try #require(description as? NSCompositeAttributeDescription) + let nested = try #require(composite.elements.first { $0.name == "location" } as? NSCompositeAttributeDescription) + #expect(nested.elements.count == 2) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func attributeDescriptionRoundTrip() throws { + for type in [Campground.LocationCoordinates.attributeType, Campground.Schedule.attributeType] { + let description = try NSAttributeDescription.make(attribute: Attribute(id: "value", type: type)) + #expect(AttributeType(attribute: description) == type) + } + // scalars round trip through the same initializer + for type in AttributeType.scalarCases { + let description = try NSAttributeDescription.make(attribute: Attribute(id: "value", type: type)) + #expect(AttributeType(attribute: description) == type) + } + } + + @Test func emptyCompositeThrows() throws { + #expect(throws: CoreDataModelError.self) { + try NSAttributeDescription.make(attribute: Attribute(id: "empty", elements: [])) + } + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func managedObjectModelIncludesComposite() throws { + // both entities, so the `units` relationship can resolve its inverse + let model = try NSManagedObjectModel(model: Model(entities: [ + EntityDescription(entity: Campground.self), + EntityDescription(entity: Campground.Unit.self) + ])) + let entity = try #require(model.entitiesByName["Campground"]) + let location = try #require(entity.attributesByName["location"]) + #expect(location is NSCompositeAttributeDescription) + #expect(AttributeType(attribute: location) == Campground.LocationCoordinates.attributeType) + } + + // MARK: - Errors + + @Test func modelErrorDescriptions() { + let errors: [CoreDataModelError] = [ + .compositeAttributesUnavailable("Campground", "location"), + .emptyCompositeAttribute("empty"), + .invalidCompositeValue("location") + ] + #expect(CoreDataModelError.errorDomain == "org.pureswift.CoreDataModel.CoreDataModelError") + // each case has a distinct code + #expect(Set(errors.map(\.errorCode)) == [1, 2, 3]) + for error in errors { + #expect(error.description.isEmpty == false) + #expect(error.errorUserInfo[NSLocalizedDescriptionKey] as? String == error.description) + // and bridges to NSError with that domain and code + let nsError = error as NSError + #expect(nsError.domain == CoreDataModelError.errorDomain) + #expect(nsError.code == error.errorCode) + #expect(nsError.localizedDescription == error.description) + } + } + + /// A CoreData type with no CoreModel equivalent has no attribute type. + @Test func attributeTypeFromUnsupportedDescription() { + let transformable = NSAttributeDescription() + transformable.name = "value" + transformable.attributeType = .transformableAttributeType + #expect(AttributeType(attribute: transformable) == nil) + } + + /// A description marked composite that isn't the composite subclass carries no + /// elements, so it cannot produce an attribute type. + @Test func attributeTypeFromMalformedComposite() { + let malformed = NSAttributeDescription() + malformed.name = "value" + malformed.attributeType = .composite + #expect(AttributeType(attribute: malformed) == nil) + } + + /// A composite is only as convertible as its least convertible element. + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func attributeTypeFromCompositeWithUnsupportedElement() throws { + let element = NSAttributeDescription() + element.name = "value" + element.attributeType = .transformableAttributeType + let composite = NSCompositeAttributeDescription() + composite.name = "broken" + composite.attributeType = .composite + composite.elements = [element] + #expect(AttributeType(attribute: composite) == nil) + } + + // MARK: - Value marshalling + + @Test func compositeToFoundation() throws { + let value = AttributeValue.composite([ + "latitude": .double(40.7), + "longitude": .double(-74.0), + "label": .null + ]) + let dictionary = try #require(value.toFoundation() as? NSDictionary) + #expect(dictionary["latitude"] as? Double == 40.7) + #expect(dictionary["longitude"] as? Double == -74.0) + // null elements are omitted rather than stored as NSNull + #expect(dictionary["label"] == nil) + #expect(dictionary.count == 2) + } + + @Test func compositeFromFoundation() throws { + let elements = Campground.LocationCoordinates.attributeElements + let decoded = try AttributeValue.composite( + from: ["latitude": 40.7, "longitude": -74.0], + elements: elements + ) + #expect(decoded == ["latitude": .double(40.7), "longitude": .double(-74.0)]) + // a missing key and NSNull both decode as null, so the shape matches the schema + let partial = try AttributeValue.composite( + from: ["latitude": 40.7, "longitude": NSNull()], + elements: elements + ) + #expect(partial == ["latitude": .double(40.7), "longitude": .null]) + // an unknown stored key is ignored rather than mis-typed + let extra = try AttributeValue.composite( + from: ["latitude": 40.7, "longitude": -74.0, "altitude": 3.0], + elements: elements + ) + #expect(extra.count == 2) + } + + @Test func keyPathExpression() throws { + let expression = FetchRequest.Predicate.Expression.keyPath("location.latitude") + #expect(expression.toFoundation().keyPath == "location.latitude") + } + + // MARK: - SQLite round trip + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + private func makeStore() throws -> PersistentContainerStorage { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("Composite-\(UUID()).sqlite") + let description = NSPersistentStoreDescription(url: url) + description.type = NSSQLiteStoreType + description.shouldAddStoreAsynchronously = false + return try PersistentContainerStorage( + name: "Composite\(UUID())", + model: Model(entities: [ + EntityDescription(entity: Campground.self), + EntityDescription(entity: Campground.Unit.self) + ]), + storeDescriptions: [description] + ) + } + + private static func campground( + name: String, + latitude: Double, + longitude: Double, + start: UInt = 9, + end: UInt = 17 + ) -> Campground { + Campground( + name: name, + address: "\(name) Road", + location: Campground.LocationCoordinates(latitude: latitude, longitude: longitude), + descriptionText: name, + officeHours: Campground.Schedule(start: start, end: end) + ) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func sqliteRoundTrip() async throws { + let store = try makeStore() + // high precision values double as a floating point fidelity check + let campground = Self.campground( + name: "North", + latitude: 34.51446212994721, + longitude: -89.15318142250365 + ) + try await store.insert(campground) + let fetched = try await store.fetch(Campground.self, for: campground.id) + #expect(fetched?.location.latitude == 34.51446212994721) + #expect(fetched?.location.longitude == -89.15318142250365) + #expect(fetched?.officeHours == Campground.Schedule(start: 9, end: 17)) + // the value is structured in the store, not a flattened string + let data = try #require(try await store.fetch(Campground.entityName, for: ObjectID(campground.id))) + #expect(data.attributes["location"] == .composite([ + "latitude": .double(34.51446212994721), + "longitude": .double(-89.15318142250365) + ])) + } + + /// The load bearing claim: CoreData compiles a namespaced composite key path + /// into the SQL it issues for a fetch request. + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func sqliteElementPredicate() async throws { + let store = try makeStore() + let north = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + let south = Self.campground(name: "South", latitude: 25.8, longitude: -80.2) + try await store.insert([try north.encode(), try south.encode()]) + let results = try await store.fetch( + Campground.self, + predicate: "location.latitude" > 30 + ) + #expect(results.map(\.name) == ["North"]) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func sqliteElementSort() async throws { + let store = try makeStore() + let north = Self.campground(name: "North", latitude: 40.7, longitude: -74.0, start: 9) + let south = Self.campground(name: "South", latitude: 25.8, longitude: -80.2, start: 6) + try await store.insert([try north.encode(), try south.encode()]) + let results = try await store.fetch( + Campground.self, + sortDescriptors: [.init(property: "officeHours.start", ascending: true)] + ) + #expect(results.map(\.name) == ["South", "North"]) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func sqliteUpdateComposite() async throws { + let store = try makeStore() + var campground = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + try await store.insert(campground) + campground.officeHours = Campground.Schedule(start: 1, end: 2) + campground.location = Campground.LocationCoordinates(latitude: 1, longitude: 2) + try await store.insert(campground) + let fetched = try await store.fetch(Campground.self, for: campground.id) + #expect(fetched?.officeHours == Campground.Schedule(start: 1, end: 2)) + #expect(fetched?.location == Campground.LocationCoordinates(latitude: 1, longitude: 2)) + } +} + +#endif diff --git a/Tests/CoreModelTests/CompositeEvaluationTests.swift b/Tests/CoreModelTests/CompositeEvaluationTests.swift new file mode 100644 index 0000000..979c128 --- /dev/null +++ b/Tests/CoreModelTests/CompositeEvaluationTests.swift @@ -0,0 +1,211 @@ +// +// CompositeEvaluationTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import Testing +@testable import CoreModel + +/// Predicate and sort evaluation against composite attribute key paths. +@Suite struct CompositeEvaluationTests { + + private static func campground( + id: ObjectID, + latitude: Double, + longitude: Double, + start: UInt, + end: UInt + ) -> ModelData { + ModelData( + entity: Campground.entityName, + id: id, + attributes: [ + "name": .string("Camp \(id.rawValue)"), + "location": .composite([ + "latitude": .double(latitude), + "longitude": .double(longitude) + ]), + "officeHours": .composite([ + "start": .int64(Int64(start)), + "end": .int64(Int64(end)) + ]) + ], + relationships: [:] + ) + } + + private let north = campground(id: "north", latitude: 40.7, longitude: -74.0, start: 9, end: 17) + private let south = campground(id: "south", latitude: 25.8, longitude: -80.2, start: 7, end: 15) + + // MARK: - Key path evaluation + + @Test func elementKeyPath() { + #expect(("location.latitude" > 30).evaluate(with: north)) + #expect(("location.latitude" > 30).evaluate(with: south) == false) + #expect(("location.longitude" < -70).evaluate(with: north)) + #expect(("officeHours.start" == 9).evaluate(with: north)) + } + + @Test func wholeCompositeEquality() { + let predicate = "location".compare(.equalTo, .attribute(.composite([ + "latitude": .double(40.7), + "longitude": .double(-74.0) + ]))) + #expect(predicate.evaluate(with: north)) + #expect(predicate.evaluate(with: south) == false) + } + + @Test func nestedElementKeyPath() { + let data = ModelData( + entity: "Test", + id: "1", + attributes: [ + "address": .composite([ + "street": .string("1 Main"), + "location": .composite([ + "latitude": .double(40.7), + "longitude": .double(-74.0) + ]) + ]) + ], + relationships: [:] + ) + #expect(("address.location.latitude" > 30).evaluate(with: data)) + #expect(("address.location.latitude" > 50).evaluate(with: data) == false) + #expect(("address.street" == "1 Main").evaluate(with: data)) + } + + @Test func unresolvableKeyPath() { + // an element the composite doesn't declare + #expect(("location.altitude" > 0).evaluate(with: north) == false) + // descending into a scalar + #expect(("name.length" > 0).evaluate(with: north) == false) + // descending past a leaf + #expect(("location.latitude.degrees" > 0).evaluate(with: north) == false) + // an unresolvable path is null, matching a missing top-level key + #expect("location.altitude".compare(.equalTo, .attribute(.null)).evaluate(with: north)) + } + + /// The root variable of a `Foundation.Predicate` converts to an empty key path, + /// which previously reached `PropertyKey(rawValue: "")` and tripped its assertion. + @Test func emptyKeyPath() { + let expression = FetchRequest.Predicate.Expression.keyPath(PredicateKeyPath(keys: [])) + #expect(expression.evaluate(with: north, functions: [:]) == nil) + } + + @Test func compositeIsNotOrderable() { + let predicate = "location".compare(.lessThan, .attribute(.composite([ + "latitude": .double(99.0) + ]))) + #expect(predicate.evaluate(with: north) == false) + } + + // MARK: - Relationship traversal into composites + + /// A key path may traverse a relationship and *then* descend into a composite + /// element of the related object, e.g. `units.checkout.start`. + @Test func relationshipThenCompositeKeyPath() { + let early = ModelData( + entity: Campground.Unit.entityName, + id: "early", + attributes: ["checkout": .composite(["start": .int64(6), "end": .int64(10)])], + relationships: ["campground": .toOne("north")] + ) + let late = ModelData( + entity: Campground.Unit.entityName, + id: "late", + attributes: ["checkout": .composite(["start": .int64(11), "end": .int64(14)])], + relationships: ["campground": .toOne("north")] + ) + let campground = ModelData( + entity: Campground.entityName, + id: "north", + attributes: ["name": .string("North")], + relationships: ["units": .toMany([early.id, late.id])] + ) + let objects = [early.id: early, late.id: late, campground.id: campground] + + let anyEarly = "units.checkout.start".compare(.any, .equalTo, [], .attribute(.int64(6))) + #expect(anyEarly.evaluate(with: campground, objects: objects)) + + let anyMissing = "units.checkout.start".compare(.any, .equalTo, [], .attribute(.int64(99))) + #expect(anyMissing.evaluate(with: campground, objects: objects) == false) + + // every unit checks out before noon + let allBeforeNoon = "units.checkout.start".compare(.all, .lessThan, [], .attribute(.int64(12))) + #expect(allBeforeNoon.evaluate(with: campground, objects: objects)) + + // an element the composite doesn't declare resolves to nothing + let unknown = "units.checkout.midday".compare(.any, .equalTo, [], .attribute(.int64(6))) + #expect(unknown.evaluate(with: campground, objects: objects) == false) + } + + /// The same, through a to-one relationship. + @Test func toOneRelationshipThenCompositeKeyPath() { + let campground = ModelData( + entity: Campground.entityName, + id: "north", + attributes: [ + "name": .string("North"), + "location": .composite(["latitude": .double(40.7), "longitude": .double(-74.0)]) + ], + relationships: [:] + ) + let unit = ModelData( + entity: Campground.Unit.entityName, + id: "unit", + attributes: [:], + relationships: ["campground": .toOne(campground.id)] + ) + let objects = [campground.id: campground, unit.id: unit] + #expect(("campground.location.latitude" > 30).evaluate(with: unit, objects: objects)) + #expect(("campground.location.latitude" > 50).evaluate(with: unit, objects: objects) == false) + } + + // MARK: - Sorting + + @Test func sortByElement() { + let all = [north, south] + let ascending = all.sorted(by: [.init(property: "location.latitude", ascending: true)]) + #expect(ascending.map(\.id) == ["south", "north"]) + let descending = all.sorted(by: [.init(property: "location.latitude", ascending: false)]) + #expect(descending.map(\.id) == ["north", "south"]) + // and on a different composite + let byHours = all.sorted(by: [.init(property: "officeHours.start", ascending: true)]) + #expect(byHours.map(\.id) == ["south", "north"]) + } + + @Test func sortByUnresolvableElementIsStable() { + // uncomparable values fall through to the object identifier tiebreaker + let sorted = [north, south].sorted(by: [.init(property: "location.altitude", ascending: true)]) + #expect(sorted.map(\.id) == ["north", "south"]) + } + + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @Test func sortComparatorByElement() { + let comparator = FetchRequest.SortDescriptor(property: "location.latitude", ascending: true) + #expect(comparator.compare(south, north) == .orderedAscending) + #expect(comparator.compare(north, south) == .orderedDescending) + #expect(comparator.compare(north, north) == .orderedSame) + } + + /// A property whose name literally contains a dot still resolves directly, so + /// existing models are unaffected by the key path convention. + @Test func literalDottedPropertyNameWins() { + let data = ModelData( + entity: "Test", + id: "1", + attributes: [ + "location.latitude": .double(1), + "location": .composite(["latitude": .double(2)]) + ], + relationships: [:] + ) + #expect(("location.latitude" == 1).evaluate(with: data)) + let sorted = [data].sorted(by: [.init(property: "location.latitude", ascending: true)]) + #expect(sorted.count == 1) + } +} diff --git a/Tests/CoreModelTests/CompositeNestedTests.swift b/Tests/CoreModelTests/CompositeNestedTests.swift new file mode 100644 index 0000000..1e0508c --- /dev/null +++ b/Tests/CoreModelTests/CompositeNestedTests.swift @@ -0,0 +1,292 @@ +// +// CompositeNestedTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import Testing +@testable import CoreModel +#if canImport(CoreData) +import CoreData +@testable import CoreDataModel +#endif + +/// Nested composite attributes — a composite whose element is itself a composite — +/// exercised identically against the in-memory store and CoreData. +/// +/// Every assertion lives in a `Self.assert…` helper taking `some ModelStorage`, so the +/// two backends are held to the same behavior rather than to two hand-written copies. +@Suite(.serialized) struct CompositeNestedTests { + + static let model = Model(entities: [ + EntityDescription(entity: Facility.self) + ]) + + static func facility( + name: String, + street: String, + city: String? = "Springfield", + latitude: Double, + longitude: Double, + billing: Address? = nil + ) -> Facility { + Facility( + name: name, + address: Address( + street: street, + city: city, + location: Campground.LocationCoordinates(latitude: latitude, longitude: longitude) + ), + billingAddress: billing + ) + } + + // MARK: - Shared assertions + + /// A nested composite survives a round trip with every level intact. + static func assertRoundTrip(_ store: some ModelStorage) async throws { + let facility = facility( + name: "North", + street: "1 Main", + latitude: 34.51446212994721, + longitude: -89.15318142250365 + ) + try await store.insert(facility) + let fetched = try #require(try await store.fetch(Facility.self, for: facility.id)) + #expect(fetched == facility) + #expect(fetched.address.street == "1 Main") + #expect(fetched.address.city == "Springfield") + // the innermost level keeps full floating point precision + #expect(fetched.address.location.latitude == 34.51446212994721) + #expect(fetched.address.location.longitude == -89.15318142250365) + } + + /// The stored value is nested structure, not a flattened key space. + static func assertStoredValueIsNested(_ store: some ModelStorage) async throws { + let facility = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0) + try await store.insert(facility) + let data = try #require(try await store.fetch(Facility.entityName, for: ObjectID(facility.id))) + #expect(data.attributes["address"] == .composite([ + "street": .string("1 Main"), + "city": .string("Springfield"), + "location": .composite([ + "latitude": .double(40.7), + "longitude": .double(-74.0) + ]) + ])) + // there is no flattened key for the nested element + #expect(data.attributes["address.location"] == nil) + #expect(data.attributes["address.location.latitude"] == nil) + } + + /// A predicate can address an element two levels deep. + static func assertNestedPredicate(_ store: some ModelStorage) async throws { + let north = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0) + let south = facility(name: "South", street: "2 Oak", latitude: 25.8, longitude: -80.2) + try await store.insert([try north.encode(), try south.encode()]) + let deep = try await store.fetch( + Facility.self, + predicate: "address.location.latitude" > 30 + ) + #expect(deep.map(\.name) == ["North"]) + // and one level deep, on the same composite + let shallow = try await store.fetch( + Facility.self, + predicate: "address.street" == "2 Oak" + ) + #expect(shallow.map(\.name) == ["South"]) + } + + /// A sort descriptor can address an element two levels deep. + static func assertNestedSort(_ store: some ModelStorage) async throws { + let north = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0) + let south = facility(name: "South", street: "2 Oak", latitude: 25.8, longitude: -80.2) + try await store.insert([try north.encode(), try south.encode()]) + let ascending = try await store.fetch( + Facility.self, + sortDescriptors: [.init(property: "address.location.latitude", ascending: true)] + ) + #expect(ascending.map(\.name) == ["South", "North"]) + let descending = try await store.fetch( + Facility.self, + sortDescriptors: [.init(property: "address.location.latitude", ascending: false)] + ) + #expect(descending.map(\.name) == ["North", "South"]) + } + + /// An optional composite is absent as a whole, while a nested optional *element* + /// is null within a composite that is itself present. + static func assertNullHandling(_ store: some ModelStorage) async throws { + let facility = facility( + name: "North", + street: "1 Main", + city: nil, + latitude: 40.7, + longitude: -74.0 + ) + try await store.insert(facility) + let fetched = try #require(try await store.fetch(Facility.self, for: facility.id)) + #expect(fetched.billingAddress == nil) + #expect(fetched.address.city == nil) + #expect(fetched.address.street == "1 Main") + let data = try #require(try await store.fetch(Facility.entityName, for: ObjectID(facility.id))) + // the absent composite is null as a whole, not a dictionary of nulls + #expect(data.attributes["billingAddress"] == .null) + // the absent element is null inside a composite that is present + guard case let .composite(address)? = data.attributes["address"] else { + Issue.record("Expected a composite address") + return + } + #expect(address["city"] == .null) + #expect(address["street"] == .string("1 Main")) + } + + /// Both nested composites are independently addressable when both are populated. + static func assertTwoNestedComposites(_ store: some ModelStorage) async throws { + let facility = facility( + name: "North", + street: "1 Main", + latitude: 40.7, + longitude: -74.0, + billing: Address( + street: "PO Box 9", + city: "Shelbyville", + location: Campground.LocationCoordinates(latitude: 1.5, longitude: 2.5) + ) + ) + try await store.insert(facility) + let fetched = try #require(try await store.fetch(Facility.self, for: facility.id)) + #expect(fetched.address.location.latitude == 40.7) + #expect(fetched.billingAddress?.location.latitude == 1.5) + #expect(fetched.billingAddress?.street == "PO Box 9") + // each is reachable by its own nested key path + let matched = try await store.fetch( + Facility.self, + predicate: "billingAddress.location.latitude" == 1.5 + ) + #expect(matched.map(\.name) == ["North"]) + } + + /// Replacing a nested composite replaces it whole, at every level. + static func assertNestedUpdate(_ store: some ModelStorage) async throws { + var facility = facility(name: "North", street: "1 Main", latitude: 40.7, longitude: -74.0) + try await store.insert(facility) + facility.address.location = Campground.LocationCoordinates(latitude: 1, longitude: 2) + facility.address.street = "3 Elm" + try await store.insert(facility) + let fetched = try #require(try await store.fetch(Facility.self, for: facility.id)) + #expect(fetched.address.street == "3 Elm") + #expect(fetched.address.location == Campground.LocationCoordinates(latitude: 1, longitude: 2)) + // setting the optional composite back to nil clears it + facility.billingAddress = nil + try await store.insert(facility) + let cleared = try #require(try await store.fetch(Facility.self, for: facility.id)) + #expect(cleared.billingAddress == nil) + } + + // MARK: - In-memory store + + @Test func inMemoryRoundTrip() async throws { + try await Self.assertRoundTrip(InMemoryModelStorage(model: Self.model)) + } + + @Test func inMemoryStoredValueIsNested() async throws { + try await Self.assertStoredValueIsNested(InMemoryModelStorage(model: Self.model)) + } + + @Test func inMemoryNestedPredicate() async throws { + try await Self.assertNestedPredicate(InMemoryModelStorage(model: Self.model)) + } + + @Test func inMemoryNestedSort() async throws { + try await Self.assertNestedSort(InMemoryModelStorage(model: Self.model)) + } + + @Test func inMemoryNullHandling() async throws { + try await Self.assertNullHandling(InMemoryModelStorage(model: Self.model)) + } + + @Test func inMemoryTwoNestedComposites() async throws { + try await Self.assertTwoNestedComposites(InMemoryModelStorage(model: Self.model)) + } + + @Test func inMemoryNestedUpdate() async throws { + try await Self.assertNestedUpdate(InMemoryModelStorage(model: Self.model)) + } + + // MARK: - CoreData + + #if canImport(CoreData) + + /// - Note: An explicit SQLite store, since CoreData refuses composite attributes + /// in atomic (in-memory, XML, binary) stores. + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + static func makeCoreDataStore() throws -> PersistentContainerStorage { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("Nested-\(UUID()).sqlite") + let description = NSPersistentStoreDescription(url: url) + description.type = NSSQLiteStoreType + description.shouldAddStoreAsynchronously = false + return try PersistentContainerStorage( + name: "Nested\(UUID())", + model: Self.model, + storeDescriptions: [description] + ) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataRoundTrip() async throws { + try await Self.assertRoundTrip(try Self.makeCoreDataStore()) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataStoredValueIsNested() async throws { + try await Self.assertStoredValueIsNested(try Self.makeCoreDataStore()) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataNestedPredicate() async throws { + try await Self.assertNestedPredicate(try Self.makeCoreDataStore()) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataNestedSort() async throws { + try await Self.assertNestedSort(try Self.makeCoreDataStore()) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataNullHandling() async throws { + try await Self.assertNullHandling(try Self.makeCoreDataStore()) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataTwoNestedComposites() async throws { + try await Self.assertTwoNestedComposites(try Self.makeCoreDataStore()) + } + + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataNestedUpdate() async throws { + try await Self.assertNestedUpdate(try Self.makeCoreDataStore()) + } + + /// The nested element list reaches CoreData as a nested `NSCompositeAttributeDescription`. + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + @Test func coreDataNestedSchema() throws { + let managedObjectModel = try NSManagedObjectModel(model: Self.model) + let entity = try #require(managedObjectModel.entitiesByName["Facility"]) + let address = try #require(entity.attributesByName["address"] as? NSCompositeAttributeDescription) + #expect(address.elements.count == 3) + let location = try #require(address.elements.first { $0.name == "location" } as? NSCompositeAttributeDescription) + #expect(location.elements.count == 2) + #expect(location.elements.map(\.name).sorted() == ["latitude", "longitude"]) + // elements are optional at every level + #expect(address.elements.allSatisfy { $0.isOptional }) + #expect(location.elements.allSatisfy { $0.isOptional }) + // and the whole tree round trips back + #expect(AttributeType(attribute: address) == Address.attributeType) + } + + #endif +} diff --git a/Tests/CoreModelTests/CompositeStoreTests.swift b/Tests/CoreModelTests/CompositeStoreTests.swift new file mode 100644 index 0000000..084c3ae --- /dev/null +++ b/Tests/CoreModelTests/CompositeStoreTests.swift @@ -0,0 +1,118 @@ +// +// CompositeStoreTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import Testing +@testable import CoreModel + +/// Composite attributes through `InMemoryModelStorage`. +@Suite struct CompositeStoreTests { + + static let model = Model(entities: [ + EntityDescription(entity: Campground.self), + EntityDescription(entity: Campground.Unit.self) + ]) + + private static func campground( + name: String, + latitude: Double, + longitude: Double, + start: UInt = 9, + end: UInt = 17 + ) -> Campground { + Campground( + name: name, + address: "\(name) Road", + location: Campground.LocationCoordinates(latitude: latitude, longitude: longitude), + descriptionText: name, + officeHours: Campground.Schedule(start: start, end: end) + ) + } + + @Test func roundTrip() async throws { + let store = InMemoryModelStorage(model: Self.model) + let campground = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + try await store.insert(campground) + let fetched = try await store.fetch(Campground.self, for: campground.id) + #expect(fetched == campground) + #expect(fetched?.location.latitude == 40.7) + #expect(fetched?.officeHours == Campground.Schedule(start: 9, end: 17)) + } + + /// The stored value must be structured, not the flattened string it used to be. + @Test func storedValueIsComposite() async throws { + let store = InMemoryModelStorage(model: Self.model) + let campground = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + try await store.insert(campground) + let data = try #require(try await store.fetch(Campground.entityName, for: ObjectID(campground.id))) + #expect(data.attributes["location"] == .composite([ + "latitude": .double(40.7), + "longitude": .double(-74.0) + ])) + } + + @Test func elementPredicate() async throws { + let store = InMemoryModelStorage(model: Self.model) + let north = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + let south = Self.campground(name: "South", latitude: 25.8, longitude: -80.2) + try await store.insert([try north.encode(), try south.encode()]) + let results = try await store.fetch( + Campground.self, + predicate: "location.latitude" > 30 + ) + #expect(results.map(\.name) == ["North"]) + } + + @Test func elementSort() async throws { + let store = InMemoryModelStorage(model: Self.model) + let north = Self.campground(name: "North", latitude: 40.7, longitude: -74.0, start: 9) + let south = Self.campground(name: "South", latitude: 25.8, longitude: -80.2, start: 6) + try await store.insert([try north.encode(), try south.encode()]) + let results = try await store.fetch( + Campground.self, + sortDescriptors: [.init(property: "officeHours.start", ascending: true)] + ) + #expect(results.map(\.name) == ["South", "North"]) + } + + /// A composite is replaced whole on insert, rather than merged element-wise. + @Test func updateReplacesComposite() async throws { + let store = InMemoryModelStorage(model: Self.model) + var campground = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + try await store.insert(campground) + campground.location = Campground.LocationCoordinates(latitude: 1, longitude: 2) + try await store.insert(campground) + let fetched = try await store.fetch(Campground.self, for: campground.id) + #expect(fetched?.location == Campground.LocationCoordinates(latitude: 1, longitude: 2)) + } + + /// Reading normalizes a partially specified composite, so a declared element is + /// never simply missing. + @Test func normalizesPartialComposite() async throws { + let store = InMemoryModelStorage(model: Self.model) + let campground = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + var data = try campground.encode() + data.attributes["location"] = .composite(["latitude": .double(40.7)]) + try await store.insert(data) + let fetched = try #require(try await store.fetch(Campground.entityName, for: data.id)) + #expect(fetched.attributes["location"] == .composite([ + "latitude": .double(40.7), + "longitude": .null + ])) + } + + /// An absent composite normalizes to `.null`, not to a dictionary of nulls. + @Test func normalizesAbsentComposite() async throws { + let store = InMemoryModelStorage(model: Self.model) + let campground = Self.campground(name: "North", latitude: 40.7, longitude: -74.0) + var data = try campground.encode() + data.attributes["location"] = nil + try await store.insert(data) + let fetched = try #require(try await store.fetch(Campground.entityName, for: data.id)) + #expect(fetched.attributes["location"] == .null) + } +} diff --git a/Tests/CoreModelTests/CoreDataModelTests.swift b/Tests/CoreModelTests/CoreDataModelTests.swift index b4ea1cb..87ce3a5 100644 --- a/Tests/CoreModelTests/CoreDataModelTests.swift +++ b/Tests/CoreModelTests/CoreDataModelTests.swift @@ -22,7 +22,7 @@ import Testing static func makeContext() throws -> NSManagedObjectContext { let model = Model(entities: Person.self, Event.self) - let coordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel(model: model)) + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: try NSManagedObjectModel(model: model)) try coordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) context.persistentStoreCoordinator = coordinator @@ -312,7 +312,7 @@ import Testing let model = Model(entities: Person.self, Event.self) let container = NSPersistentContainer( name: "Test\(UUID())", - managedObjectModel: NSManagedObjectModel(model: model) + managedObjectModel: try NSManagedObjectModel(model: model) ) try container.syncLoadPersistentStores() let person = Person(name: "Alice", age: 30) @@ -335,8 +335,8 @@ import Testing url: URL(fileURLWithPath: "/nonexistent-\(UUID())/store.sqlite") ) description.type = NSSQLiteStoreType - let storage = PersistentStorageTests.makeStorage(model: Model(entities: Person.self, Event.self)) - let failing = PersistentContainerStorage( + let storage = try PersistentStorageTests.makeStorage(model: Model(entities: Person.self, Event.self)) + let failing = try PersistentContainerStorage( name: "Failing\(UUID())", model: Model(entities: Person.self, Event.self), storeDescriptions: [description] @@ -360,7 +360,7 @@ import Testing url: URL(fileURLWithPath: "/nonexistent-\(UUID())/store.sqlite") ) description.type = NSSQLiteStoreType - let failing = PersistentContainerStorage( + let failing = try PersistentContainerStorage( name: "Failing\(UUID())", model: Model(entities: Person.self, Event.self), storeDescriptions: [description] diff --git a/Tests/CoreModelTests/CoreDataTests.swift b/Tests/CoreModelTests/CoreDataTests.swift index e05c656..35a4bc6 100644 --- a/Tests/CoreModelTests/CoreDataTests.swift +++ b/Tests/CoreModelTests/CoreDataTests.swift @@ -16,7 +16,7 @@ import Testing @Suite(.serialized) struct CoreDataTests { - @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) @Test func coreData() async throws { @@ -28,7 +28,7 @@ struct CoreDataTests { Campground.Unit.self ) - let managedObjectModel = NSManagedObjectModel(model: model) + let managedObjectModel = try NSManagedObjectModel(model: model) let store = NSPersistentContainer( name: "Test\(UUID())", @@ -108,7 +108,7 @@ struct CoreDataTests { func customFunctionInMemory() throws { let model = Model(entities: Person.self, Event.self) - let coordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel(model: model)) + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: try NSManagedObjectModel(model: model)) try coordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) context.persistentStoreCoordinator = coordinator diff --git a/Tests/CoreModelTests/CoreModelTests.swift b/Tests/CoreModelTests/CoreModelTests.swift index 5644f87..420c5d7 100644 --- a/Tests/CoreModelTests/CoreModelTests.swift +++ b/Tests/CoreModelTests/CoreModelTests.swift @@ -120,14 +120,14 @@ extension Campground: EntityTestInfo { "created": .date, "updated": .date, "address": .string, - "location": .string, + "location": Campground.LocationCoordinates.attributeType, "amenities": .string, "phoneNumber": .string, "descriptionText": .string, "timeZone": .int32, "notes": .string, "directions": .string, - "officeHours": .string + "officeHours": Campground.Schedule.attributeType ] } static var expectedRelationships: [String : (type: RelationshipType, destinationEntity: String, inverseRelationshipKey: String?)] { @@ -144,7 +144,7 @@ extension Campground.Unit: EntityTestInfo { "name": .string, "notes": .string, "amenities": .string, - "checkout": .string + "checkout": Campground.Schedule.attributeType ] } static var expectedRelationships: [String : (type: RelationshipType, destinationEntity: String, inverseRelationshipKey: String?)] { diff --git a/Tests/CoreModelTests/PersistentStorageTests.swift b/Tests/CoreModelTests/PersistentStorageTests.swift index 687001c..08e4c7a 100644 --- a/Tests/CoreModelTests/PersistentStorageTests.swift +++ b/Tests/CoreModelTests/PersistentStorageTests.swift @@ -113,10 +113,15 @@ struct AllTypes: Equatable, Hashable, Codable, Identifiable { // `if #available` instead. @Suite(.serialized) struct PersistentStorageTests { - static func makeStorage(model: Model = Model(entities: Person.self, Event.self, AllTypes.self)) -> PersistentContainerStorage { + /// - Warning: This is an atomic (in-memory) store, which CoreData refuses to use for + /// a model containing composite attributes — it raises an uncatchable + /// `NSInvalidArgumentException`. Do not add a composite attribute to `AllTypes` or + /// any other entity used here; see `CompositeCoreDataTests` for the SQLite-backed + /// composite coverage. + static func makeStorage(model: Model = Model(entities: Person.self, Event.self, AllTypes.self)) throws -> PersistentContainerStorage { let description = NSPersistentStoreDescription() description.type = NSInMemoryStoreType - return PersistentContainerStorage( + return try PersistentContainerStorage( name: "Test\(UUID())", model: model, storeDescriptions: [description] @@ -146,7 +151,7 @@ struct AllTypes: Equatable, Hashable, Codable, Identifiable { guard #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) else { return } - let storage = Self.makeStorage() + let storage = try Self.makeStorage() var value = Self.makeAllTypes() try await storage.insert(value) var fetched = try await storage.fetch(AllTypes.self, for: value.id) @@ -164,7 +169,7 @@ struct AllTypes: Equatable, Hashable, Codable, Identifiable { guard #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) else { return } - let storage = Self.makeStorage() + let storage = try Self.makeStorage() let people = [ Person(name: "Alice", age: 30), Person(name: "Bob", age: 25), @@ -215,7 +220,7 @@ struct AllTypes: Equatable, Hashable, Codable, Identifiable { guard #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) else { return } - let storage = Self.makeStorage() + let storage = try Self.makeStorage() try await storage.register(function: DatabaseFunction(name: "upperName", argumentCount: 1) { arguments in guard case let .string(name) = arguments[0] else { return nil } return .string(name.uppercased()) @@ -237,7 +242,7 @@ struct AllTypes: Equatable, Hashable, Codable, Identifiable { guard #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) else { return } - let storage = Self.makeStorage() + let storage = try Self.makeStorage() let person = Person(name: "Alice", age: 30) try await storage.insert(person) let viewContext = try storage.viewContext @@ -268,7 +273,7 @@ struct AllTypes: Equatable, Hashable, Codable, Identifiable { let model = Model(entities: Person.self, Event.self, AllTypes.self) let container = NSPersistentContainer( name: "Test\(UUID())", - managedObjectModel: NSManagedObjectModel(model: model) + managedObjectModel: try NSManagedObjectModel(model: model) ) container.persistentStoreDescriptions.forEach { $0.shouldAddStoreAsynchronously = false } try container.syncLoadPersistentStores() diff --git a/Tests/CoreModelTests/TestModel.swift b/Tests/CoreModelTests/TestModel.swift index c405a1c..9c16177 100644 --- a/Tests/CoreModelTests/TestModel.swift +++ b/Tests/CoreModelTests/TestModel.swift @@ -89,9 +89,9 @@ public struct Campground: Equatable, Hashable, Codable, Identifiable { @Attribute public var address: String - @Attribute(.string) + @CompositeAttribute public var location: LocationCoordinates - + @Attribute(.string) public var amenities: [Amenity] @@ -111,7 +111,7 @@ public struct Campground: Equatable, Hashable, Codable, Identifiable { @Attribute public var directions: String? - @Attribute(.string) + @CompositeAttribute public var officeHours: Schedule @Relationship(destination: Unit.self, inverse: .campground) @@ -242,23 +242,30 @@ public extension Campground { } } -extension Campground.LocationCoordinates: AttributeEncodable { - - public var attributeValue: AttributeValue { - return .string("\(latitude),\(longitude)") +extension Campground.LocationCoordinates: CompositeAttributeCodable { + + public enum CodingKeys: String, CodingKey { + case latitude + case longitude } -} -extension Campground.LocationCoordinates: AttributeDecodable { - - public init?(attributeValue: AttributeValue) { - guard let string = String(attributeValue: attributeValue) else { - return nil - } - let components = string.components(separatedBy: ",") - guard components.count == 2, - let latitude = Double(components[0]), - let longitude = Double(components[1]) else { + public static var attributeElements: [Attribute] { + [ + Attribute(id: PropertyKey(CodingKeys.latitude), type: .double), + Attribute(id: PropertyKey(CodingKeys.longitude), type: .double) + ] + } + + public var compositeValue: [PropertyKey: AttributeValue] { + var value = [PropertyKey: AttributeValue]() + value.encode(latitude, forKey: CodingKeys.latitude) + value.encode(longitude, forKey: CodingKeys.longitude) + return value + } + + public init?(compositeValue: [PropertyKey: AttributeValue]) { + guard let latitude = compositeValue.decode(Double.self, forKey: CodingKeys.latitude), + let longitude = compositeValue.decode(Double.self, forKey: CodingKeys.longitude) else { return nil } self.init(latitude: latitude, longitude: longitude) @@ -282,26 +289,38 @@ public extension Campground { } } -extension Campground.Schedule: AttributeEncodable { - - public var attributeValue: AttributeValue { - return .string("\(start),\(end)") +extension Campground.Schedule: CompositeAttributeCodable { + + public enum CodingKeys: String, CodingKey { + case start + case end } -} -extension Campground.Schedule: AttributeDecodable { - - public init?(attributeValue: AttributeValue) { - guard let string = String(attributeValue: attributeValue) else { - return nil - } - let components = string.components(separatedBy: ",") - guard components.count == 2, - let start = UInt(components[0]), - let end = UInt(components[1]) else { + public static var attributeElements: [Attribute] { + [ + // - Note: `.int64`, since `UInt.attributeValue` is `.int64`. + Attribute(id: PropertyKey(CodingKeys.start), type: .int64), + Attribute(id: PropertyKey(CodingKeys.end), type: .int64) + ] + } + + public var compositeValue: [PropertyKey: AttributeValue] { + var value = [PropertyKey: AttributeValue]() + value.encode(start, forKey: CodingKeys.start) + value.encode(end, forKey: CodingKeys.end) + return value + } + + public init?(compositeValue: [PropertyKey: AttributeValue]) { + guard let start = compositeValue.decode(UInt.self, forKey: CodingKeys.start), + let end = compositeValue.decode(UInt.self, forKey: CodingKeys.end) else { return nil } - self.init(start: start, end: end) + // - Note: Assigns the stored properties directly rather than calling + // `init(start:end:)`, whose `assert(start < end)` would trap in debug builds + // when decoding arbitrary stored data. + self.start = start + self.end = end } } @@ -325,7 +344,7 @@ public extension Campground { @Attribute(.string) public var amenities: [Amenity] - @Attribute(.string) + @CompositeAttribute public var checkout: Schedule public init( @@ -355,3 +374,96 @@ public extension Campground { } } } + +// MARK: - Nested Composite Attributes + +/// A street address, whose `location` element is itself a composite. +public struct Address: Equatable, Hashable, Codable, Sendable { + + public var street: String + + /// An optional scalar element, to exercise a null inside a nested composite. + public var city: String? + + public var location: Campground.LocationCoordinates + + public init(street: String, city: String? = nil, location: Campground.LocationCoordinates) { + self.street = street + self.city = city + self.location = location + } +} + +extension Address: CompositeAttributeCodable { + + public enum CodingKeys: String, CodingKey { + case street + case city + case location + } + + public static var attributeElements: [Attribute] { + [ + Attribute(id: PropertyKey(CodingKeys.street), type: .string), + Attribute(id: PropertyKey(CodingKeys.city), type: .string), + Attribute(id: PropertyKey(CodingKeys.location), composite: Campground.LocationCoordinates.self) + ] + } + + public var compositeValue: [PropertyKey: AttributeValue] { + var value = [PropertyKey: AttributeValue]() + value.encode(street, forKey: CodingKeys.street) + value.encode(city, forKey: CodingKeys.city) + value.encode(location, forKey: CodingKeys.location) + return value + } + + public init?(compositeValue: [PropertyKey: AttributeValue]) { + guard let street = compositeValue.decode(String.self, forKey: CodingKeys.street), + let location = compositeValue.decode(Campground.LocationCoordinates.self, forKey: CodingKeys.location) else { + return nil + } + self.init( + street: street, + city: compositeValue.decode(String.self, forKey: CodingKeys.city), + location: location + ) + } +} + +/// An entity with a required and an optional nested composite attribute. +@Entity("Facility") +public struct Facility: Equatable, Hashable, Codable, Identifiable { + + public let id: UUID + + @Attribute + public var name: String + + @CompositeAttribute + public var address: Address + + /// An optional composite, which is absent as a whole rather than element-wise. + @CompositeAttribute + public var billingAddress: Address? + + public init( + id: UUID = UUID(), + name: String, + address: Address, + billingAddress: Address? = nil + ) { + self.id = id + self.name = name + self.address = address + self.billingAddress = billingAddress + } + + public enum CodingKeys: CodingKey { + + case id + case name + case address + case billingAddress + } +}