Skip to content

Latest commit

 

History

History
1040 lines (740 loc) · 26.2 KB

File metadata and controls

1040 lines (740 loc) · 26.2 KB

PersistentModel Guide

@PersistentModel brings a Swift-first declaration style to Core Data models while keeping the runtime on top of NSManagedObject.

This guide is written for library users. It focuses on:

  • the supported declaration style
  • generated APIs and metadata
  • required modeling rules
  • common mistakes and the diagnostics you should expect

The current design is intentionally strict. The goal is predictable code generation, stable macro expansion, and tooling-friendly validation.

CoreDataEvolution re-exports CoreData, so normal model source files usually only need:

import CoreDataEvolution

You do not normally need a separate import CoreData.

Before You Start

@PersistentModel is a source-layer representation of a Core Data model.

For production use, you should still have a real .xcdatamodeld or .xcdatamodel.

That means:

  • you still model entities and attributes in Xcode
  • you still control migrations at the Core Data model layer
  • @PersistentModel gives you a better Swift-facing declaration of that model
  • cde-tool helps keep the source layer and the model layer aligned

If you read this guide with a SwiftData mindset, the easiest mental model is:

  • SwiftData @Model tries to be the model
  • CoreDataEvolution @PersistentModel is the Swift source representation of the Core Data model you already have

Overview

A @PersistentModel type is still an NSManagedObject subclass. You declare stored properties in Swift, and the macros generate the boilerplate needed for:

  • Core Data key-value accessors
  • typed Keys and path
  • field metadata for sort and predicate building
  • runtime schema metadata for test/debug-only model construction
  • optional relationship helper methods

The macro system works together with these supporting macros:

  • @Attribute
  • @Ignore
  • @Composition
  • @CompositionField
  • @Relationship

Minimal Example

import CoreDataEvolution

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  var title: String = ""
  var timestamp: Date? = nil
}

Requirements shown in this example:

  • The type must be a class.
  • The type must inherit from NSManagedObject.
  • The type must declare @objc(EntityName) explicitly.
  • Every persisted property must be optional, or provide a default value.

What @PersistentModel Generates

For a valid model type, the macro generates:

  • Keys
  • Paths
  • PathRoot
  • path
  • __cdFieldTable
  • __cdRelationshipProjectionTable
  • internal relationship-target validation helpers
  • __cdRuntimeEntitySchema
  • conditional fetchRequest() when the type does not already declare one
  • optional convenience init(...) when generateInit: true
  • to-many relationship getters
  • unordered to-many helpers: single-value and batch addTo* / removeFrom*
  • ordered to-many helpers: single-value and batch addTo* / removeFrom*, plus insertInto*(_:at:)
  • no generated to-many setters
  • PersistentEntity conformance
  • CDRuntimeSchemaProviding conformance

You should treat these generated members as implementation details. Write your model declarations in source, and let the macro own the generated layer.

Declaring Attributes

Plain stored properties default to persisted attributes:

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  var title: String = ""
  var notes: String? = nil
  var createdAt: Date = .distantPast
}

Use @Attribute when you need more control.

Rename a Persistent Field

@Attribute(persistentName: "name")
var title: String = ""

This means:

  • Swift property name: title
  • Core Data persistent field name: name

This is intentionally different from SwiftData's originalName.

  • In SwiftData, originalName is a migration hint. It means "this property used to be named something else in an older schema version."
  • In CoreDataEvolution, persistentName is a storage mapping. It means "this Swift property is backed by this Core Data field name right now."

Example:

// SwiftData-style migration meaning
@Attribute(originalName: "name")
var title: String

// CoreDataEvolution storage mapping meaning
@Attribute(persistentName: "name")
var title: String = ""

This renamed mapping also feeds the library's typed path system automatically. You still write Swift-facing property names in code, while sort and predicate construction resolve them to the correct Core Data field name internally.

Example:

let sort = try NSSortDescriptor(
  Item.self,
  path: Item.path.title,
  order: .asc
)

let predicate = NSPredicate(
  format: "%K == %@",
  Item.path.title.raw,
  "hello"
)

With @Attribute(persistentName: "name") var title: String = "", the generated path still uses title in Swift code, but maps it to the persistent field name when building %K-based predicates or sort descriptors.

For a dedicated guide to this mapping layer, including the motivation, NSSortDescriptor, NSPredicate, and to-many quantifier examples, see TypedPathGuide.md.

Unique Constraint

@Attribute(.unique)
var slug: String = ""

.unique is a simple trait. It represents a single-field uniqueness constraint.

Transient Attribute

@Attribute(.transient)
var cachedSummary: String = ""

transient means:

  • the property belongs to the Core Data model
  • the value is not persisted to the store
  • it is different from @Ignore

Current restrictions for transient:

  • only supported with .default storage
  • cannot be combined with .unique
  • cannot be combined with .raw, .codable, .transformed, or .composition

Raw-Representable Storage

enum Status: String {
  case draft
  case published
}

@Attribute(storageMethod: .raw)
var status: Status? = .draft

Use .raw for enums or other RawRepresentable types.

Codable Storage

struct ItemConfig: Codable, Equatable {
  var retryCount: Int = 0
}

@Attribute(storageMethod: .codable)
var config: ItemConfig? = nil

Value Transformer Storage

@Attribute(storageMethod: .transformed(name: "CDEStringListTransformer"))
var keywords: [String]? = nil

For schema-backed models, .transformed(...) means the Core Data field type should match the transformer's stored output type.

Example:

  • if the transformer stores NSString, the model field should be String
  • if the transformer stores NSData, the model field should be Binary Data
  • only model the field as Transformable when the schema intentionally uses a transformable payload path such as NSSecureUnarchiveFromData

Transformer-backed attributes currently support two source forms:

  • .transformed(CDEStringListTransformer.self)
  • .transformed(name: "CDEStringListTransformer")

For schema-backed models, the name: form is the canonical shape because Core Data stores the transformer registration name in the model itself. In both forms, the transformer must be registered before the property is first accessed, for example during app launch or test bootstrap.

Decode Failure Policy

Decode failure policies only make sense for storage methods that actually decode values.

@Attribute(
  storageMethod: .codable,
  decodeFailurePolicy: .fallbackToDefaultValue
)
var config: ItemConfig? = nil

Supported policies:

  • .fallbackToDefaultValue
  • .debugAssertNil

For .codable and .transformed, .fallbackToDefaultValue now effectively means "fallback to nil". These storage methods currently require optional declarations and do not support non-nil source defaults, so there is no separate model-backed value to restore.

decodeFailurePolicy does not apply to .composition. Composition properties are currently optional-only, but that rule is enforced through the storage method itself rather than through decode-failure configuration.

For a dedicated guide to storage choices, tradeoffs, and current limits of .default, .raw, .codable, .transformed, and .composition, see StorageMethodGuide.md.

Declaring Relationships

Relationship shape is still inferred from the Swift property type:

  • Tag? -> to-one relationship
  • Set<Tag> -> unordered to-many relationship
  • [Tag] -> ordered to-many relationship

That only answers the cardinality question.

Every relationship still needs explicit relationship metadata in source:

  • inverse
  • deleteRule
  • persistentName when the Swift property name differs from the Core Data relationship name

Important:

  • inverse always points to the relationship name stored in the Core Data model on the other side
  • it does not point to the other Swift property name

So a real relationship declaration looks like this:

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  @Relationship(inverse: "items", deleteRule: .nullify)
  var tag: Tag?

  @Relationship(inverse: "owner", deleteRule: .nullify)
  var tags: Set<Tag>

  @Relationship(inverse: "orderedOwner", deleteRule: .nullify)
  var orderedTags: [Tag]
}

There is no separate public inverse-only relationship macro.

To-many relationship accessors return ordinary Swift collections:

  • Set<Target> bridges from the underlying NSSet
  • [Target] bridges from the underlying NSOrderedSet

That bridge constructs a new Swift collection on every read. This is the intended convenience API, but it also means repeated access to a large to-many relationship is not free. For hot paths, prefer a fetch request or another query-oriented API instead of repeatedly reading the relationship property.

To-many relationships intentionally generate getters only.

  • T? generates a getter and setter
  • Set<T> generates a getter, but no setter
  • [T] generates a getter, but no setter

Mutate to-many relationships through the generated helper methods instead. Helper names derive from the Swift property name, not persistentName.

// Unordered to-many: var tags: Set<Tag>
func addToTags(_ value: Tag)
func removeFromTags(_ value: Tag)
func addToTags(_ values: Set<Tag>)
func removeFromTags(_ values: Set<Tag>)

// Ordered to-many: var orderedTags: [Tag]
func addToOrderedTags(_ value: Tag)
func removeFromOrderedTags(_ value: Tag)
func addToOrderedTags(_ values: [Tag])
func removeFromOrderedTags(_ values: [Tag])
func insertIntoOrderedTags(_ value: Tag, at index: Int)

Required Relationship Rules

To-one relationships must be optional

Valid:

var category: Category?

Invalid:

var category: Category

Why:

  • the current model rules require relationship declarations to follow a predictable optional/default model
  • non-optional to-one relationships are rejected at macro validation time

To-many relationships must not be declared as optional in Swift

Valid:

var tags: Set<Tag>
var orderedTags: [Tag]

Invalid:

var tags: Set<Tag>?
var orderedTags: [Tag]?

Why:

  • Core Data models for this workflow still represent to-many relationships as optional internally
  • the macro maps that model-level optional to an empty Swift collection on read
  • Swift declarations must therefore use the collection type directly
  • use Set<T> or [T], not Set<T>? or [T]?

Every relationship must have an inverse in the model

Core Data models using this library must define inverse relationships.

If the inverse is missing, tooling and validation reject the model.

Every relationship must declare @Relationship(...)

Relationship cardinality is still inferred from the Swift property type, but relationship metadata is explicit in source.

Example:

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  @Relationship(persistentName: "owner", inverse: "books", deleteRule: .nullify)
  var tag: Tag?
}

@objc(Tag)
@PersistentModel
final class Tag: NSManagedObject {
  @Relationship(persistentName: "books", inverse: "owner", deleteRule: .nullify)
  var items: Set<Item>
}

@Relationship(...) always carries:

  • persistentName when the Swift property name differs from the Core Data relationship name
  • inverse
  • deleteRule

It can also carry optional count bounds when the Core Data model declares them:

  • minimumModelCount
  • maximumModelCount

Example:

@Relationship(
  inverse: "documents",
  deleteRule: .deny,
  minimumModelCount: 1,
  maximumModelCount: 3
)
var owner: Owner?

You do not need to write these count arguments when the model uses Core Data's default bounds for that relationship shape:

  • optional to-one: minimum 0, maximum 1
  • non-optional to-one: minimum 1, maximum 1
  • to-many: minimum 0, with no upper bound unless the model explicitly constrains it. In Core Data, maxCount == 0 means "unbounded" for to-many relationships.

Write them only when the model declares non-default minimum or maximum counts.

There is no source-level inverse inference in the current model DSL.

inverse uses the persistent relationship name

When a relationship uses persistentName, the inverse argument still points to the relationship name stored in the Core Data model on the other side.

It does not point to the other Swift property name.

Example:

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  @Relationship(persistentName: "owner", inverse: "books", deleteRule: .nullify)
  var tag: Tag?
}

@objc(Tag)
@PersistentModel
final class Tag: NSManagedObject {
  @Relationship(persistentName: "books", inverse: "owner", deleteRule: .nullify)
  var items: Set<Item>
}

Currently supported delete rules:

  • .nullify
  • .cascade
  • .deny

.noAction is intentionally unsupported.

Multiple Relationships to the Same Target Entity

Example:

@objc(Document)
@PersistentModel
final class Document: NSManagedObject {
  @Relationship(inverse: "authoredDocuments", deleteRule: .nullify)
  var author: User?

  @Relationship(inverse: "editedDocuments", deleteRule: .nullify)
  var editor: User?
}

@objc(User)
@PersistentModel
final class User: NSManagedObject {
  @Relationship(inverse: "author", deleteRule: .nullify)
  var authoredDocuments: Set<Document>

  @Relationship(inverse: "editor", deleteRule: .nullify)
  var editedDocuments: Set<Document>
}

This works the same way for self-referencing models.

Self-Referencing Relationship Example

@objc(Category)
@PersistentModel
final class Category: NSManagedObject {
  @Relationship(inverse: "children", deleteRule: .nullify)
  var parent: Category?

  @Relationship(inverse: "parent", deleteRule: .nullify)
  var children: Set<Category>
}

Declaring Compositions

Use @Composition for value-like grouped data that should still participate in typed paths and model metadata.

In CoreDataEvolution, the source-level term is composition. It corresponds to Core Data's composite attribute feature at the model layer.

This feature requires the platform support behind Core Data composite attributes:

  • iOS 17+
  • macOS 14+
  • tvOS 17+
  • watchOS 10+
  • visionOS 1+
@Composition
struct Location {
  var x: Double = 0
  var y: Double? = nil
}

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  @Attribute(storageMethod: .composition)
  var location: Location? = nil
}

For a schema-backed Core Data model, this source declaration must match a real composite attribute in .xcdatamodeld.

In Xcode's model editor, that means:

  • the entity must declare a top-level attribute such as location
  • that top-level attribute's type must be Composite
  • the model must also declare the matching composite type, for example Location
  • that composite type must contain the leaf fields, such as x and y

Do not model schema-backed .composition as a Transformable attribute in Xcode. The runtime schema/test-only helpers can use a transformable dictionary payload, but a real .xcdatamodeld workflow should use a real Core Data composite attribute.

Also do not flatten the composition into top-level entity fields such as lat and lng while keeping the Swift source as var location: Location?. If the entity does not actually contain a top-level composite attribute named location, assigning location at runtime will fail because the Core Data model and the Swift source are describing different shapes.

When a composition leaf needs a different Core Data field name, use @CompositionField(persistentName: ...):

@Composition
struct Location {
  @CompositionField(persistentName: "lat")
  var latitude: Double = 0

  @CompositionField(persistentName: "lng")
  var longitude: Double? = nil
}

This gives composition leaves the same kind of persistent-name mapping that attributes and relationships already support:

  • Swift-facing code still uses location.latitude
  • the generated composition field table maps that leaf to the persistent field lat
  • typed paths, sort descriptors, and %K predicate interpolation still start from the Swift path and resolve to the persistent path internally

Composition Rules

A composition type must be:

  • a struct
  • non-generic
  • made of stored var properties only
  • limited to supported primitive field types and optionals
  • non-nested
  • free of conversion behavior in the current implementation

Current rules for @CompositionField:

  • use it only on stored var fields inside a @Composition struct
  • it only supports persistentName
  • it is the only supported way to rename a composition leaf field
  • it does not reuse @Attribute

@Composition generates metadata used by:

  • typed paths
  • encode/decode helpers
  • runtime schema metadata for test/debug model construction

Ignored Properties

Use @Ignore for pure Swift properties that should not participate in the Core Data model.

@Ignore
var transientCache: [String: Int] = [:]

@Ignore means:

  • not persisted
  • not part of the generated Core Data schema
  • not part of typed path metadata
  • still included in generated init when generateInit: true

Use @Ignore for in-memory state. Use @Attribute(.transient) for Core Data transient attributes.

Default Value Rules

The current implementation keeps default-value semantics strict.

Persisted Properties

A persisted property must be one of:

  • optional
  • non-optional with an explicit default value

Valid:

var title: String = ""
var notes: String? = nil
var createdAt: Date = .distantPast

Invalid:

var title: String

The declared Swift default value should match the default configured in your Core Data model.

For example, if the model defines title with a default of "", your Swift declaration should also use:

var title: String = ""

Do not treat the Swift default as a way to override the model's default value.

The declared default value is used for these purposes:

  • to make the declaration explicit and toolable
  • to satisfy the "optional or default" model rule
  • as a fallback when custom decoding fails, depending on storage method and decode failure policy

It is not used to rewrite or replace the default value stored in the .xcdatamodeld.

Why This Rule Exists

The rule keeps several parts of the system consistent:

  • macro-generated accessors
  • tooling generate/validate
  • runtime schema generation for tests and debugging

Optional Properties

Optional properties may explicitly write = nil, but they do not have to.

Both are accepted:

var notes: String?
var notes: String? = nil

Custom Storage and Non-Optional Values

Custom storage rules are stricter than primitive .default storage.

Current rules:

  • .raw

  • .codable

  • .transformed

  • .composition

  • .codable, .transformed, and .composition currently require optional properties

  • for those three storage methods, the only supported explicit default is nil

  • .raw remains a separate case because its backing primitive can still align with a model-backed default in some cases

Generated Init

@PersistentModel does not generate an init by default.

@PersistentModel(generateInit: true)

When enabled, the generated init:

  • includes persisted attributes
  • includes persisted attributes declared with .raw, .codable, .transformed, and .composition
  • includes @Ignore properties
  • excludes relationships
  • does not inject a Core Data context parameter
  • does not assign default parameter values; callers must pass every included argument explicitly

Example:

@objc(Item)
@PersistentModel(generateInit: true)
final class Item: NSManagedObject {
  var title: String = ""

  @Attribute(storageMethod: .codable)
  var config: ItemConfig? = nil

  @Ignore
  var transientCache: [String: Int] = [:]

  @Relationship(inverse: "items", deleteRule: .nullify)
  var tags: Set<Tag>
}

Generated init shape:

convenience init(
  title: String,
  config: ItemConfig?,
  transientCache: [String: Int]
)

Runtime Schema for Tests and Debugging

@PersistentModel also emits runtime schema metadata.

This supports pure Swift model construction for:

  • tests
  • debug utilities
  • non-Xcode workflows that do not want to depend on .xcdatamodeld

This runtime schema path is intentionally limited:

  • it is test/debug-only
  • it is not a replacement for production .xcdatamodeld
  • it does not guarantee model hash or migration compatibility
  • unsupported runtime primitive types fail generation instead of silently downgrading schema

Example:

let model = try NSManagedObjectModel.makeRuntimeModel(Item.self, Tag.self)

let container = try NSPersistentContainer.makeRuntimeTest(
  modelTypes: Item.self, Tag.self
)

makeRuntimeModel and makeRuntimeTest intentionally use slightly different call shapes, but both consume the same runtime schema provider types.

PersistentModel Rules Checklist

Before using @PersistentModel, make sure all of these are true.

Type-Level Rules

  • must be a class
  • must inherit from NSManagedObject
  • must declare @objc(EntityName) explicitly
  • must not declare multiple stored properties in a single var declaration

Attribute Rules

  • every persisted attribute must be optional or have a default value
  • .unique is supported
  • .transient is supported only with .default
  • derived attributes are not supported
  • renamed attributes use persistentName:

Relationship Rules

  • to-one relationships must be optional
  • to-many relationships must use Set<T> or [T]
  • to-many relationships must not be declared as optional in Swift
  • relationships must have inverses in the Core Data model
  • every relationship must declare @Relationship(persistentName:inverse:deleteRule:)
  • relationship count bounds are optional source metadata and should only be written when the model declares non-default min/max values
  • supported relationship delete rules are .nullify, .cascade, and .deny
  • .noAction is not supported

Composition Rules

  • composition type must use @Composition
  • composition leaf renames must use @CompositionField(persistentName: ...)
  • composition type must be a non-generic struct
  • composition fields must be supported primitive fields
  • nested composition is not currently supported

Invalid Examples

Multiple Stored Properties in One Declaration

Invalid:

var title: String = "", subtitle: String = ""

Reason:

  • @PersistentModel does not support multi-binding stored declarations
  • split them into separate var declarations

Correct:

var title: String = ""
var subtitle: String = ""

Non-Optional To-One Relationship

Invalid:

var category: Category

Correct:

var category: Category?

Optional To-Many Relationship

Invalid:

var tags: Set<Tag>?

Correct:

var tags: Set<Tag>

Missing @Relationship Metadata

Invalid:

var tag: Tag?

Correct:

@Relationship(inverse: "items", deleteRule: .nullify)
var tag: Tag?

Unsupported No Action Delete Rule

Invalid:

@Relationship(inverse: "items", deleteRule: .noAction)
var tag: Tag?

Correct:

@Relationship(inverse: "items", deleteRule: .nullify)
var tag: Tag?

Transient with Unsupported Storage

Invalid:

@Attribute(.transient, storageMethod: .raw)
var cachedSummary: String = ""

Correct:

@Attribute(.transient)
var cachedSummary: String = ""

Unsupported Derived Attribute

Derived Attribute is currently out of scope.

If your Core Data model uses derived attributes, the current toolchain rejects that model.

Common Diagnostics

Examples of expected diagnostics:

  • @PersistentModel can only be attached to a class declaration.
  • @PersistentModel type must inherit from NSManagedObject.
  • @PersistentModel type must declare @objc(ClassName) explicitly.
  • @PersistentModel does not support declaring multiple stored properties in one var declaration.
  • To-one relationship properties must be optional.
  • Optional to-many relationship ... is not supported.
  • Relationship property 'tag' must declare @Relationship(inverse: ..., deleteRule: ...).
  • @Relationship does not support deleteRule: .noAction.
  • @Attribute trait .transient only supports .default storage.
  • Derived Attribute is not supported.

Recommended Style

A good style looks like this:

import CoreDataEvolution

@Composition
struct Location {
  var x: Double = 0
  var y: Double? = nil
}

enum ItemStatus: String {
  case draft
  case published
}

@objc(Item)
@PersistentModel
final class Item: NSManagedObject {
  @Attribute(.unique)
  var slug: String = ""

  @Attribute(persistentName: "name")
  var title: String = ""

  @Attribute(storageMethod: .raw)
  var status: ItemStatus? = .draft

  @Attribute(storageMethod: .composition)
  var location: Location? = nil

  @Attribute(.transient)
  var cachedSummary: String = ""

  @Ignore
  var uiState: [String: Int] = [:]

  @Relationship(inverse: "items", deleteRule: .nullify)
  var tag: Tag?
}

@objc(Tag)
@PersistentModel
final class Tag: NSManagedObject {
  var name: String = ""
  @Relationship(inverse: "tag", deleteRule: .nullify)
  var items: Set<Item>
}

This style matches the current macro, tooling, and runtime-schema expectations closely.

Current Boundaries

@PersistentModel is intentionally conservative in the current implementation.

Not supported yet:

  • Derived Attribute
  • nested composition
  • entity inheritance
  • production-oriented runtime model replacement for .xcdatamodeld
  • optional to-many declarations
  • non-optional to-one declarations

When in doubt, prefer the simplest declaration shape that matches the rules in this guide.