Web & iOS dev – Ruby, Rails, RubyMotion & React

Paul Sturgess

Using MagicalRecord and Core Data in RubyMotion

MagicalRecord is a wrapper around Apple’s Core Data Framework. Written in Objective-C, it’s one of the most popular and mature libraries for working with Core Data.

I really like it as it simplifies a lot of the code, whilst it still allows you to ‘get your hands dirty’ when necessary.

This article details how I’m using it. This is by no means the ‘perfect’ solution, as I am evolving it all the time, but it is working well for me. By all means get in touch if you think I’m missing any obvious tricks.

One thing I have found is that it’s pretty much impossible to hide the fact you are using CoreData. Particularly with the requirement of a different context for each thread. But what I do want to do is make things as simple, consistent and maintainable as possible.

Installation

At the time of writing I’m using the MagicalRecord 3.0 branch, installed via motion-cocoaopds. My Rakefile includes:

1
2
3
app.pods do
  pod 'MagicalRecord', :git => 'https://github.com/magicalpanda/MagicalRecord.git', :branch =>'release/3.0'
end

Wrapping MagicalRecord

First I have a Database class to wrap common MagicalRecord tasks.

The main reason for this class is so that I do not sprinkle MagicalRecord calls all around my code. Having them all in one place will make it easy to update if/when the MagicalRecord api changes. It also encourages consistency in my usage of MagicalRecord as, for example, there are many ways to persist your data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
class Database

  def self.filename
    "YourApplicationName.sqlite"
  end

  def self.path
    File.join(
      NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true),
      "YourApplicationName",
      self.filename
    )
  end

  def self.created?
    File.exist?(self.path)
  end

  def self.loadOrCreate
    MagicalRecord.setupAutoMigratingStack
  end

  def self.createTestDB
    MagicalRecord.setupStackWithInMemoryStore
  end

  def self.delete
    if self.created?
      self.cleanUp
      File.delete(self.path)
    end
  end

  def self.reset
    Database.delete
    Database.loadOrCreate
  end

  def self.cleanUp
    MagicalRecord.cleanUp
  end

  def self.defaultLocalContext
    MagicalRecordStack.defaultStack.context
  end

  def self.backgroundLocalContext
    NSManagedObjectContext.MR_confinementContextWithParent(defaultLocalContext)
  end

  def self.save_specific_context(localContext, callback = nil)
    localContext.MR_saveToPersistentStoreWithCompletion(
      lambda { |success, error|
        NSLog("success: %@", success)
        if success
          callback.call if callback
        else
          NSLog "Error saving Seed Data"
          NSLog("description: %@", error.description)
        end
      }
    )
  end

  def self.save_test_db!
    defaultLocalContext.MR_saveToPersistentStoreAndWait
  end

  def self.save_on_main_thread!(callback = nil)
    defaultLocalContext.MR_saveToPersistentStoreWithCompletion(
      lambda { |success, error|
        NSLog("success: %@", success)
        if success
          callback.call(defaultLocalContext) if callback
        else
          NSLog "Error saving Core Data"
          NSLog("description: %@", error.description)
        end
      }
    )
  end

  def self.save_on_background_thread!(callback = nil, completion_callback = nil)
    MagicalRecord.saveWithBlock(
      lambda { |localContext|
        callback.call(localContext) if callback
      },
      completion: lambda { |success, error|
        NSLog("success: %@", success)
        if success
          completion_callback.call if completion_callback
        else
          NSLog "Error saving Core Data"
          NSLog("description: %@", error.description)
        end
      }
    )
  end

end

In AppDelegate didFinishLaunchingWithOptions I call Database.loadOrCreate. As the name implies, this will either load my existing Core Data stack or it will set up a new one.

I also cleanup the database when my app closes via this method in the AppDelegate:

1
2
3
  def applicationWillTerminate application
    Database.cleanUp
  end

Entities

I create my Core Data entities in Xcode (although I do intend to look at the ruby-xcdm gem so I can stop using Xcode). I’m now using the ruby-xcdm gem to define my schema in code. Here’s a blog post about it.

For now, the ib gem is great for allowing us to fire up Xcode just when we need it. The gem is mostly geared around using Interface Builder but I don’t use it for that.

Once you’ve installed the gem run:

$ rake ib:open

This will open Xcode. Inside the Resources folder on the left hand side there will be a .xcdatamodeld file. Select this and you can create your entities.

One thing to remember is to set the Class of each Entity to the corresponding Class in your app. Otherwise it will be a standard NSManagedObject.

So for each Entiy I create a corresponding class like so. Note it inherits from my own CustomNSManagedObject.

1
2
3
class ToDoItem < CustomNSManagedObject
  # more methods go here
end

I give each Entity created_at and id attributes in order to help with querying.

ActiveRecord Style Behaviour

The subclass of NSManagedObject I use looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class CustomNSManagedObject < NSManagedObject

  def self.defaultContext
    CurrentSaveGame.localContext
  end

  def self.all(context = nil)
    localContext = context || defaultContext
    self.MR_findAllSortedBy("created_at", ascending:true, inContext: localContext)
  end

  def self.first(context = nil)
    localContext = context || defaultContext
    self.MR_findFirstOrderedByAttribute("created_at", ascending:true, inContext: localContext)
  end

  def self.last(context = nil)
    localContext = context || defaultContext
    self.MR_findFirstOrderedByAttribute("created_at", ascending:false, inContext: localContext)
  end

  def self.count
    self.MR_numberOfEntities
  end

  def self.find(id, context = nil)
    localContext = context || defaultContext
    self.MR_findFirstByAttribute("id", withValue:id, inContext: localContext)
  end

  def self.new(attributes)
    self.build(attributes)
  end

  def self.build(attributes = {}, context = nil)
    localContext = context || defaultContext
    model = self.MR_createInContext(localContext)
    model.setValuesForKeysWithDictionary(attributes)
    model.created_at = Time.now
    model
  end

  def switch_context(localContext)
    self.MR_inContext(localContext)
  end

  def attributes
    self.entity.attributesByName.keys.each_with_object({}) do |attribute, attributes_hash|
      attributes_hash[attribute] = self.send(attribute)
    end
  end

end

Essentially this class gives me ActiveRecord like behaviour. Also, like my Database class, it ensures I contain some more MagicalRecord calls to a single place.

This means I can do:

ToDoItem.build(
  {
    id: 1,
    action: "Lorem ipsum dolor sit amet"
  }
)

My build method automatically inserts a created_at for every new record.

Data store

By default MagicalRecord.setupAutoMigratingCoreDataStack will use SQLite to persist your data.

Note that I don’t include a save method in CustomNSManagedObject. Saving an individual record isn’t the most efficient way to persist changes in Core Data. Everything is held in memory until the relevant context is told to save. Hence why I’ve put the save method in the Database class.

Persisting data on the main thread

If you’ve used any of the CustomNSManagedObject methods like find, all, or build without passing in a context then using Database.save_on_main_thread! will persist any changes made. For example:

1
2
3
4
5
6
7
new_item = ToDoItem.build(
  {
    id: 2
    action: "Some other todo item"
  }
)
Database.save_on_main_thread!

Persisting data on background threads

However, if updates are made on a background thread, then we need a new (temporary) context which is handled by Database.save_on_background_thread!.

For example:

1
2
3
4
5
6
7
8
9
10
11
Database.save_on_background_thread!(
  lambda { |localContext|
    new_item = ToDoItem.build(
      {
        id: 2
        action: "Some other todo item"
      },
      localContext
    )
  }
)

Alternatively if you don’t want to wrap everything in the block. Create a localContext and hold on to it. Make your changes and then save later down the line.

1
2
3
4
5
6
7
8
9
localContext = Database.backgroundLocalContext
new_item = ToDoItem.build(
  {
    id: 2
    action: "Some other todo item"
  },
  localContext
)
Database.save_specific_context(localContext)

Note most of the class methods in CustomNSManagedObject take an optional context so they can be used on background threads.

Testing

For tests I use the in memory data store and when saving it is synchronous. This ensures the data is there before the assertions!

For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe "SomeTest" do

  before do
    Database.cleanUp
    Database.createTestDB
    @localContext = Database.defaultLocalContext
  end

  describe "some_method" do

    context "when some scenario" do

      it "should assign something is true" do
        # ...
      end

    end

  end

end