Skip to content

Composite Attribute Support - #36

Merged
colemancda merged 46 commits into
masterfrom
feature/composite-attribute
Aug 16, 2026
Merged

Composite Attribute Support#36
colemancda merged 46 commits into
masterfrom
feature/composite-attribute

Conversation

@colemancda

Copy link
Copy Markdown
Member

Closes #11.

Adds composite attributes — a named attribute whose value is a dictionary of named sub-attributes — modeled on Core Data's NSCompositeAttributeDescription (iOS 17 / macOS 14).

Previously a struct-valued property had to be hand-flattened into a single scalar. The test model shows what that cost: location was stored as "34.51446,-89.15318", opaque to the store, unqueryable, unsortable, and needing a bespoke string grammar plus parser per type. Campground.LocationCoordinates and Schedule are now real composites, so the existing end-to-end CoreData assertions became composite coverage for free.

Design

Value AttributeValue.composite([PropertyKey: AttributeValue])
Schema AttributeType.composite([Attribute])
Entity.attributes unchanged ([CodingKeys: AttributeType])

Neither enum needs indirectArray/Dictionary already break the layout cycle, which avoids a box allocation per value and keeps swift_allocBox out of Embedded builds.

Two constraints Core Data enforces at runtime are unrepresentable here: an element can never be a relationship (AttributeType has no relationship case), and the element tree is a finite value, so it can't be recursive.

Source-breaking changes

  • AttributeType loses String RawRepresentable and CaseIterable (an associated value precludes both). Replaced by scalarRawValue / init?(scalarRawValue:) / scalarCases. Deliberately not re-added as a lossy RawRepresentable, since .composite would round-trip to nil and silently corrupt schemas.
  • NSManagedObjectModel.init(model:) is now throws, propagating to NSEntityDescription.init(entity:) and PersistentContainerStorage.init. Composites can't be represented below macOS 14, and this is the only point that holds the whole model and can fail before any data is written.
  • Out-of-tree backends (CoreModel-SQLite, CoreModel-MongoDB) will need updating: non-exhaustive switches over both enums, the rawValue/allCases replacements above, and SortTerm.property may now carry a dotted path. MongoDB maps .composite to an embedded document; SQLite to a JSON column with json_extract.

Wire format for scalars is unchangedAttributeType has hand-written Codable so {"id":"name","type":"string"} still encodes byte-for-byte, asserted by a test. Only models that actually use composites get the new nested shape.

Coding

CompositeAttribute / CompositeAttributeEncodable / CompositeAttributeDecodable refine the existing protocols, so composites flow through ModelData.encode/decode and the Optional conformances with no new overloads and no change to the macro's encode/decode codegen.

extension Campground.LocationCoordinates: CompositeAttributeCodable {

    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]) { /* ... */ }
}
@CompositeAttribute
public var location: LocationCoordinates
// generates: .location: LocationCoordinates.attributeType

@Attribute(LocationCoordinates.attributeType) also works as an escape hatch with no macro involvement, and is covered by a test.

Predicates and sorting

PredicateValue.init?(data:keyPath:) walks PredicateKeyPath into composite values, so location.latitude > 30 and sorting by officeHours.start work in the pure-Swift evaluator. This is load-bearing beyond the in-memory store: FetchRequest.Predicate.evaluate(with:) is also Core Data's custom-function fallback path, which would otherwise return wrong results.

No new SortTerm case — a dotted property is the key path, matching NSSortDescriptor(key:) and MongoDB's sort spec. A property whose name literally contains a dot still resolves directly, so existing models are unaffected.

Comparison semantics needed no changes: equality is recursive dictionary equality, and composites are uncomparable for ordering (order returns nil), consistent with .data/.uuid.

Also fixes a latent debug trap: the Foundation.Predicate root variable converts to an empty key path, which previously reached PropertyKey(rawValue: "") and tripped its assertion.

Verified rather than assumed

  • Embedded Swift compiles the recursive value types (SWIFT_EMBEDDED=1, wasm SDK).
  • Core Data's SQLite store really does compile location.latitude into a fetch predicatesqliteElementPredicate proves it against a real store rather than trusting the documentation.
  • Atomic stores reject composites with an uncatchable Objective-C exception: NSInvalidArgumentException, "Core Data provided atomic stores do not support composite attributes". Since that aborts the process and can't be caught from Swift, it's documented as a precondition on NSManagedObjectModel.init(model:), with a warning on the in-memory test helper so nobody adds a composite to AllTypes.

Notes

  • amenities stays @Attribute(.string)[Amenity] is variable-length, and a composite is a fixed set of named elements. It also keeps a regression test that the flat-string path still works alongside composites.
  • CoreDataTests and BatchInsertTests move to macOS 14 / iOS 17, since their model now contains composites. All pure-CoreModel tests still run everywhere.
  • The ten integer AttributeDecodable conformances gained one case each rather than being collapsed onto a shared helper — that refactor would change overflow behavior from trapping to nil and belongs in its own PR.

203 tests pass (up from 141). Builds clean with macros on, macros off, and under Embedded Swift.

@github-code-quality

github-code-quality Bot commented Aug 16, 2026

Copy link
Copy Markdown

Code Coverage Overview

Languages: Swift

Swift / code-coverage/llvm-cov

The overall coverage in commit e1be582 in the feature/composite-at... branch is 96%. The coverage in commit 04ecd57 in the master branch is 95%.

Show a code coverage summary of the most impacted files.
File master 04ecd57 feature/composite-at... e1be582 +/-
Sources/CoreDat...scription.swift 100% 89% -11%
Sources/CoreDat...scription.swift 100% 92% -8%
Sources/CoreMod.../Evaluate.swift 97% 96% -1%
Sources/CoreDat...ibuteType.swift 98% 98% 0%
Sources/CoreDat...jectModel.swift 79% 81% +2%
Sources/CoreMod...ryStorage.swift 92% 96% +4%
Sources/CoreDat...gedObject.swift 72% 79% +7%
Sources/CoreMod...ibuteType.swift 0% 100% +100%
Sources/CoreDat...odelError.swift 0% 100% +100%
Sources/CoreMod...ertyValue.swift 0% 100% +100%

Updated August 16, 2026 16:39 UTC

@colemancda
colemancda merged commit 8a6ac0d into master Aug 16, 2026
48 of 50 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Composite Attribute Support

1 participant