Give Your Models Superpowers: Generating Core Data Entities from Plain Models

July 13, 2021
Give Your Models Superpowers: Generating Core Data Entities from Plain Models

If you are an iOS developer, chances are you are very familiar with Core Data. You have likely used it for anything from simply caching relational (and non-relational) data, to the more advanced capabilities such as complex transaction-based operations.

However, while Core Data is an incredibly powerful framework with a variety of useful features, it is beginning to show its age, especially since Swift became the de facto standard for iOS development.

Because Core Data is an Objective-C framework, we often forget many of the nice features that Swift brought to the table, namely:

  1. Structs
  2. Enums (with or without associated values), unless they conform to RawRepresentable
  3. Type-safety in predicates for sorting, filtering, etc.


 

To add to that, to leverage other Swift goodies such as value types, sum types, etc, we will frequently have to manually write mappings, duplicate models, and glue-code to keep the elegance of working with these Swift constructs while maintaining compatibility with Core Data.

But, let’s automate that. Welcome, Core Data Generator (CDG)!

Backed by Sourcery, CDG offers automatic generation of Core Data entities, relationships, and various operations (fetching, insertion, updating, deletion, sorting, filtering) from plain Swift models including classes, structs and enums, and even enums with associated values.

Let's take a look at some examples of what CDG can do for us, starting with simple examples and then moving on to more advanced use-cases.

Hello world

Let’s start with this simple Swift class with two members, a: String and b: Int, and create a Core Data entity from it.

class SimpleModel {
    var id: String = "unique_id"
    let b: Int = 1
}

We can simply add the ManagedObject Sourcery annotation to it, and mark one of the fields as primaryKey.

/// sourcery: ManagedObject
class SimpleModel {
    // sourcery: primaryKey
    var id: String = "unique_id"
    let b: Int = 1
}

Every model that we want to generate a Core Data entity from needs to have exactly one field that represents the primary key. If we forget to put a primaryKey annotation, the generator will emit an #error(...) statement, causing the code to fail at compile time.

If we don't want to manually specify the unique identifier for each instance, we can add the autoId annotation to our type and change the id variable to optional:

/// sourcery: ManagedObject, autoId
class SimpleModel {
    // sourcery: primaryKey
    var id: String?
    let b: Int = 1
}

After simply running the Sourcery code generation target, our Core Data entity will be generated.

@objc(ManagedSimpleModel)
final public class ManagedSimpleModel: PersistableManagedObject, KeyPathable {
    	@nonobjc public class func fetchRequest() -> NSFetchRequest<ManagedSimpleModel> {
   	     return NSFetchRequest<ManagedSimpleModel>(entityName: "ManagedSimpleModel")
    }

    @NSManaged public var id: String
    @NSManaged public var value: NSNumber
    // Some meta fields and methods omitted
}

The generator will also create and populate NSEntityDescription for our model, including all NSPropertyDescription entries and NSRelationshipDescription entries if our model had any relationships. No need to manually create anything using the visual model editor or via code. Neat!

In addition to that, these convenience methods and static functions will also be generated for our model instances or on the model type itself:

extension SimpleModel: Persistable, UniqueIDConstraintKeyPath {
    public static var idKeyPath: WritableKeyPath<SimpleModel, ManagedSimpleModel.EntityID> {
 	       return \SimpleModel.id
 	   }

 	   public static func count(groupID: String? = nil, using predicate: NSPredicate = .true, sourceContext: NSManagedObjectContext = CoreDataStore.shared.mainContext) -> Int { ... }

 	   public static func get(entityID: ManagedSimpleModel.EntityID, sourceContext: NSManagedObjectContext = CoreDataStore.shared.mainContext) -> SimpleModel? { ... }

 	   public static func get(groupID: String? = nil, using predicate: NSPredicate, comparisonClauses: [ComparisonClause] = [], sourceContext: NSManagedObjectContext = CoreDataStore.shared.mainContext) -> [SimpleModel] { ... }

 	   public static func getAll(groupID: String? = nil, comparisonClauses: [ComparisonClause] = [], sourceContext: NSManagedObjectContext = CoreDataStore.shared.mainContext) -> [SimpleModel] { ... }

  	  public static func create(groupID: String? = nil,
                              updateIfEntityExists: Bool,
                              updateClosure: @escaping (_ entity: inout SimpleModel, _ context: NSManagedObjectContext) -> Void,
                              completeClosure: ((Result<SimpleModel, PersistableError>) -> Void)?) 
                              { ... }

  	  public static func createEntity(groupID: String? = nil, entityID: ManagedSimpleModel.EntityID, context: NSManagedObjectContext) -> SimpleModel? { ... }

  	  public static func createTemporary(groupID: String? = nil, updateClosure: @escaping (inout SimpleModel, NSManagedObjectContext) -> Void) {
        ManagedSimpleModel.createTemporary { ... }

  	  public func createAndPopulate(groupID: String? = nil, updateIfEntityExists: Bool = true, insertionPolicy: BatchInsertionPolicy = .insertOrUpdate, completeClosure: ((Result<Airplane, PersistableError>) -> Void)?) { ... }
    }

    public func update(updateClosure: @escaping (inout SimpleModel, NSManagedObjectContext) -> Void, completeClosure: ((SimpleModel) -> Void)?) { ... }

    public func delete(sourceContext: NSManagedObjectContext = CoreDataStore.shared.newBackgroundContext, completeClosure: (() -> Void)?) { ... }

    public static func delete(with options: DeleteOptions = DeleteOptions(), completeClosure: (() -> Void)?) { ... }

    public static func createBatchAndPopulate<PersistableType: Persistable & UniqueIDConstraintKeyPath>(groupID: String? = nil, from plainModels: [PersistableType], insertionPolicy: BatchInsertionPolicy = .insertOrUpdate, completeClosure: ((Result<[PersistableType], PersistableError>) -> Void)?) { ... }

    public static func getAllGroups(sourceContext: NSManagedObjectContext = CoreDataStore.shared.mainContext) -> [String]? { ... }
}

We can use these to perform the most common operations (fetching, insertion, updating, deletion) with various predicates (for filtering, sorting...) on the plain model itself, without interfacing with Core Data at all, as if it's not even there. How cool is that?

Now let's put all of this generated goodness to use.

let simpleModel = SimpleModel()
simpleModel.createAndPopulate { result: Result<SimpleModel, PersistableError> in 
    switch result {
        case .success(let insertedModel):
            print(insertedModel) // Our model was created and inserted successfully
   	     case .failure(let error):
            print(error) // Oops! Something went wrong
    }
}

Since the createAndPopulate method is also generated as an array extension, this would also work:

func insertSimpleModelInstances(instances: [SimpleModel]) {
     instances.createAndPopulate { result in 
        // …
     }
}

Now that our instance was stored and saved, we can fetch it at any time using:

let simpleModelInstance = SimpleModel.get("unique_id")
// simpleModelInstance is of type SimpleModel?

This will fetch the managed object counterpart from the store, and automatically convert it to our plain model class instance.

Since we can't guarantee that the requested entity with the specified unique identifier will exist, get returns an optional.

Structs

With CDG, structs work right out of the box although with some limitations that we’ll dive into later.

If we simply changed our model definition from:

/// sourcery: ManagedObject
class SimpleModel {
    /// sourcery: primaryKey
    var id: String = "unique_id"
    let b: Int = 1
}

... to:

/// sourcery: ManagedObject
struct SimpleModel {
    /// sourcery: primaryKey
    var id: String = "unique_id"
    let b: Int = 1
}

... and ran the generation target again, everything would keep working.

Relationships

CDG enables automatic management of relationships between models, including to-one and to-many relationships, in two situations:

  1. Relationships from nested models
  2. Relationships from flat model structures via IDs.

Let's take a look.

Simple relationships

To-one

/// sourcery: ManagedObject
struct MainModel {
    /// sourcery: primaryKey
    var id: String
    let details: String
    let nestedModel: NestedModel
}

/// sourcery: ManagedObject
struct NestedModel {
    /// sourcery: primaryKey
    var id: String
    let info: String
}

let mainModel = MainModel(id: "1", details: "test-details", nestedModel: NestedModel(id: "1", info: "test info"))
mainModel.createAndPopulate { result in 
    // …
}

After running the Sourcery code generation target, CDG will have generated entities for both models, along with the necessary NSRelationshipDescription entries automatically.

From Core Data's point of view, MainModel and NestedModel are two separate NSManagedObject entries, tied together with a relationship from MainModel to NestedModel via the id field of NestedModel. CDG will also automatically generate the necessary inverse relationship, required by Core Data to ensure data consistency.

Additionally, CDG supports optional nested models, so if we wanted to change the nested model variable to let nestedModel: NestedModel?, we could.

When fetching the managed MainModel entry, Core Data will automatically fetch the managed NestedModel via their relationship, and everything will be recursively converted into our plain models.

let mainModel = MainModel.get("1")
// Some(MainModel(id: "1", details: "test-details", nestedModel: NestedModel(id: "1", info: "test-info")))

To-many

Working with arrays of nested models also works out of the box, both for non-nullable and optional array types.

/// sourcery: ManagedObject
struct MainModel {
    /// sourcery: primaryKey
    var id: String
    let details: String
    let nestedModels: [NestedModel] // <- now an array
}

/// sourcery: ManagedObject
struct NestedModel {
    /// sourcery: primaryKey
    var id: String
    let info: String
}

Cycles

Since cycles aren’t supported with structs in Swift, working with cyclical references is supported with classes only.

/// sourcery: ManagedObject
class MainModel {
    /// sourcery: primaryKey
    var id: String
    let details: String
    let nestedModel: NestedModel // <- 
}

/// sourcery: ManagedObject
struct NestedModel {
    /// sourcery: primaryKey
    var id: String
    let info: String
    weak var parent: MainModel? // <- weak reference here to avoid retain cycle
}

Advanced relationships

Sometimes, we may encounter relational data that is fragmented, i.e. received from more than one server endpoint. We may also have situations where the data is cyclical in nature, thus unable to be serialized to a tree-like structure directly, but rather arriving in a flat structure, with only identifiers to mark relationships between them.

CDG provides automatic resolving of relationships between such objects, regardless of insertion order.

Let's see how this works:

To-one

// Our plain models used to deserialize network response

public class ParentModel {
    public var id: String
    public let someData: String
    public let childId: String // <- this is the relationship identifier from parent to child 
}

public struct ChildModel {
    public var id: String
    public let info: String
}

As we can see above, our models have no direct relationships between each other. Instead, we simply have the childId member in ParentModel which specifies the ID of the ChildModel that belongs to the parent.

So how can we create an actual relationship between them? By simply adding one additional field and marking our childId field with the relationshipIdentifier annotation:

/// sourcery: ManagedModel
public class ParentModel {
    /// sourcery: primaryKey
    public var id: String
 	   public let someData: String
  	  /// sourcery: relationshipIdentifier = "child"
  	  public let childId: String 

    	public internal(set) var child: ChildModel? = nil // <- this field will be magically populated
}

/// sourcery: ManagedModel
public struct ChildModel {
    /// sourcery: primaryKey
    public var id: String
    public let info: String
}

If we insert an entry of ParentModel, the relationship child will remain unpopulated (nil), until an instance of ChildModel whose primary key (id) matches the ParentModel.childId value is also inserted. At that point, the relationship will automatically be resolved, and fetching the ParentModel entry will have the child relationship populated and ready to go.

let parentModel = ParentModel(id: "parent-1", someData: "some-data", childId: "child-1")
let childModel = ChildModel(id: "child-1", info: "some-info")

parentModel.createAndPopulate { ... }
childModel.createAndPopulate { ... }

let fetchedParentModel = ParentModel.get("parent-1")
/* Some(
    ParentModel(
        id: "parent-1", 
        someData: "some-data", 
        childId: "child-1", 
     	   child: Some(ChildModel(
     	               id: "child-1", 
      	              info: "some-info")
     	               )
      	          )
  	  )
)
*/

Note that the insertion order doesn't matter. If we were to reverse the insertion order in the example from:

parentModel.createAndPopulate { ... }
childModel.createAndPopulate { ... } 

To:

childModel.createAndPopulate { ... } 
parentModel.createAndPopulate { ... }

... everything would still work. Nifty, huh?

To-many

To-many relationships also work out-of-the-box. For example:

/// sourcery: ManagedModel
public class ParentModel {
    /// sourcery: primaryKey
    public var id: String
  	  public let someData: String
  	  /// sourcery: relationshipIdentifier = "children"
 	   public let childrenIds: [String] // <- this is now an array of identifiers 

  	  public internal(set) var children: [ChildModel]? = nil // <- note that we changed the type to Array<ChildModel> here
}

/// sourcery: ManagedModel
public struct ChildModel {
    /// sourcery: primaryKey
    public var id: String
    public let info: String
}

Enums

CDG supports working with enums of all kinds, including RawRepresentable enums, enums with simple cases and cases with associated values or a mix of both.

For enums whose cases have associated values, the underlying managed object has different members depending on the type of the associated value:

  1. For associated values with primitive types, the managed object will contain an appropriate directly mappable type

    • NSNumber for Bool, Int, Float, Double and Decimal
    • String for String
  2. Associated values with complex types (i.e. structs or classes) will be mapped to relationships on the containing managed object.

In practice, both of these types will behave as expected, with everything working right away.

Note that the only currently unsupported scenario is an enum with cases whose associated values are also enums.

Example:

/// sourcery: ManagedObject, autoId
struct Project {
    /// sourcery: primaryKey
    var id: String? = nil
    let projectName: String
}

/// sourcery: ManagedObject, autoId
struct User {
    /// sourcery: primaryKey
    var id: String? = nil
    let userName: String
}

/// sourcery: ManagedObject
enum Data {
    case noData
    case project(Project)
    case projectWithUser(Project, User)
}

/// sourcery: ManagedObject, autoId
struct MainModel {
    /// sourcery: primaryKey
    var id: String? = nil
    let data: Data
}

let project = Project(projectName: "My Project")
let user = User(userName: "My User")

let mainModel = MainModel(data: .projectWithUser(project, user))
mainModel.createAndPopulate { result in 
    // …
}

After having inserted mainModel, we can later fetch our main model entry and it's data field will contain the relevant enum instance.

Optional enums are also supported, as well as arrays of enums (optional or not):

// same `Project`, `User` and `Data` definitions as before
	
/// sourcery: ManagedObject, autoId
struct MainModel {
    /// sourcery: primaryKey
    var id: String? = nil
    let dataEntries: [Data]?
}

Type-safe predicates

Another important feature of CDG is the ability to write type-safe predicates which get evaluated at compile time.

This can help prevent a multitude of problems:

  • Predicates failing at runtime due to incompatible types
  • Typos in predicate syntax again failing at runtime

Let’s dive right in.

Comparison

Let's take a look at an example from before:

/// sourcery: ManagedObject
struct MainModel {
    /// sourcery: primaryKey
    var id: String
    let details: String
    let nestedModel: NestedModel
}

/// sourcery: ManagedObject
struct NestedModel {
    /// sourcery: primaryKey
    var id: String
    let info: String
    let otherInfo: Int
}

If we want to fetch MainModel entries by filtering over NestedModel.info field, we'd normally have to create a predicate manually:

let predicate = NSPredicate(format: "nestedModel.info == %@", "value")

As mentioned earlier, if we make a typo, or provide a value of the wrong type, this predicate will fail at runtime. Annoying. So, how do we fix this?

CDG lets you use type-safe keypath-based predicates. Let's look at this very same example written in a type-safe manner using keypaths.

let predicate = \MainModel.nestedModel.info == "value"

Attempting to compare with an incompatible type, say an Int, would yield an error at compile time. And since we're now using keypaths, we also get code completion, so it's almost impossible to make a typo (and if we did, we'd still get a compile time error instead of at runtime).

Now we can use our predicate to perform a filtered fetch of MainModel entries:

let predicate = \MainModel.nestedModel.info == "value"
let result = Results<MainModel>().filterBy(predicate).objects

Compound predicates

CDG supports compound predicates using the && (and) and || (or) operators.

Example:

// same `MainModel` and `NestedModel` definitions as before

let predicate = \MainModel.nestedModel.info == "value" && \NestedModel.nestedModel.otherInfo > 5
let result = Results<MainModel>().filterBy(predicate).objects

Filtering to-many relationships

We can also apply filtering on to-many relationships, represented as arrays of structs/classes in our plain models. In order to do this, while still preserving type-safety and compile time guarantees, we need to use a special kind of keypath wrapper - the CompoundKeyPath.

The compound keypath actually consists of two keypaths:

  1. The keypath to the array containing our struct/class instances.
  2. The keypath to a field inside that struct/class.

Since this may seem a bit confusing, here's what it would look like in code:

/// sourcery: ManagedObject
struct MainModel {
    /// sourcery: primaryKey
    var id: String
    let details: String
    let nestedModels: [NestedModel]
}

/// sourcery: ManagedObject
struct NestedModel {
    /// sourcery: primaryKey
    var id: String
    let info: String
    let otherInfo: Int
}

// Fetching all `MainModel` entries where any of the `nestedModels` has `otherInfo` value greater than 5
let compoundKeyPath = CompoundKeyPath<MainModel>(rootKeyPath: \MainModel.nestedModels, relationshipKeyPath: \NestedModel.otherInfo, modifier: .any)
let predicate = compoundKeyPath > 5
let result = Results<MainModel>().filterBy(predicate).objects

Sorting

We can use the same keypath-based predicates to perform sorting, too:

let result = Results<MainModel>().sortBy(.ascending(\MainModel.id)).objects // ascending

let result = Results<MainModel>().sortBy(.descending(\MainModel.id)).objects // descending 

CDG Public Repo + Example Application

Over the years, we’ve worked hard on creating our CDG public repository and we’d love to share it with you, along with the installation guide and all the necessary documentation.

To navigate it more easily in our repo you’ll also find an example application to serve as your guide.

Be sure to let us know if it has helped!

The Takeaway

Thanks to CDG, working with Core Data doesn’t mean that you have to miss out on all the great things Swift provides. Swift may be the cool, younger guy on the block, but Core Data still has what it takes - you just have to arm it with the right tools.

And what about you? Have you ever tried working with CDG?