Jun 13 2009
HotCocoa and Core Data
I’ve been playing around a bit with Core Data and HotCocoa the last few days and thought I’d share what I’ve got so far. When you install MacRuby you’re provided with an XCode template to start working with Core Data. I didn’t want to use XCode so I skipped the template. Some of this code is probably pretty similar to the template as I ported the Obj-C Core Data template to Ruby.
Note, this code is lightly tested, prototype, probably wrong and has already assassinated an unknown number of kitties. At this point, I’m still not sure if the approach taken is actually sane. Time will tell, but I thought it was interesting enough to share.
I stuffed all of the default Core Data stuff into a CoreData module that I’m including into my main application (mostly so I don’t have to look at the functions when working on my main code). The separation isn’t great at the moment as the should_terminate? method is currently in the module instead of the main application. I’ll be coming back to this setup once I’ve figured out how all the pieces work.
Along with the Core Data base methods I’ve also created a ManagedObject class. Each of the entities in my model I’ve created as a model class. Those model classes inherit from ManagedObject. (I just realized, the objects returned from self.create in ManagedObject aren’t of my model types. They’ll be straight up NSManagedObjects. Oops. Will need to look at that as well. Can you tell this stuff is still pretty rough?) ManagedObject contains the common create method for all of the sub-classes. When create is called the entity will be retrieved from the Core Data model and have it’s attributes and relationships added as methods on the returned object.
What this means is that, in the end, I can do the following:
p.title = "HotData"
p.pub_date = NSDate.date
p.content = "Playing with Core Data from HotCocoa"
p.author = Author.create(:name => ‘dj2′)
p.tags.addObject(Tag.create(:name => "Core Data"))
p.tags.addObject(Tag.create(:name => "HotCocoa"))
p.published = true if !p.published?
One thing to note, when you’re working with Core Data you’ll be working with a .xcdatamodel file. In order to use that from code this data model needs to be compiled to a mom file and put into your application bundle. I’ve created a patch, attached to ticket 278 that builds this into the HotCocoa application builder. All you need is a build.yml similar to the following:
load: lib/application.rb
version: "1.0"
icon: resources/HotCocoa.icns
resources:
– resources/**/*.*
data_models:
– data/**/*.xcdatamodel
sources:
– lib/**/*.rb
Note the addition of the data_models key. This will take each of the models found, compile it, and place it in your bundles resource folder ready for use.
With that out of the way, what are we modeling? I figured I’d go simple and model a blog. I didn’t bother to write any real UI in front of it for the example as I just wanted to focus on the Core Data aspects.
The first step is to create the project: hotcocoa Blog will create the basis for us. I then updated the build.yml file to what’s seen above.
The next step was to create my data model which I saved to data/Blog.xcdatamodel. This needs to be done through XCode. Open up a new data model and create the following:
Post
- content – string (not optional)
- pub_date – date (not optional)
- published – boolean (default NO)
- title – string (not optional, default “- untitled -”)
- author – relationship to Author table (not optional)
- tags – relationship to the Tags table (optional, to-many)
Author
- name – string (not optional)
- posts – relationship to the Posts table (optional, to-many)
Tag
- name – string (not optional)
- posts – relationship to the Posts table (optional, to-many)
With the model in place we need to copy in our supporting code. This is the CoreData module and the ManagedObject class.
framework ‘CoreData’
module CoreData
def application_support_folder
return @application_support_folder if @application_support_folder
paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true)
path = (paths.count > 0) ? paths[0] : NSTemporaryDirectory
@application_support_folder = File.join(path, NSApp.name.downcase)
FileUtils.mkdir_p(@application_support_folder) unless File.exists?(@application_support_folder)
@application_support_folder
end
def managed_object_model
@managed_object_model ||= NSManagedObjectModel.mergedModelFromBundles(nil)
end
def persistent_store_coordinator
return @persistent_store_coordinator if @persistent_store_coordinator
@persistent_store_coordinator = NSPersistentStoreCoordinator.alloc.initWithManagedObjectModel(managed_object_model)
error = Pointer.new_with_type(‘@’)
url = NSURL.fileURLWithPath(File.join(application_support_folder, "#{NSApp.name}.xml"))
unless @persistent_store_coordinator.addPersistentStoreWithType(NSXMLStoreType, configuration:nil,
URL:url, options:nil, error:error)
NSApplication.sharedApplication.presentError(error[0])
end
@persistent_store_coordinator
end
def managed_object_context
return @managed_object_context if @managed_object_context
if persistent_store_coordinator
@managed_object_context = NSManagedObjectContext.alloc.init
@managed_object_context.setPersistentStoreCoordinator(persistent_store_coordinator)
end
@managed_object_context
end
def returning_undo_manager
managed_object_context.undoManager
end
def save
error = Pointer.new_with_type(‘@’)
unless managed_object_context.save(error)
NSApplication.sharedApplication.presentError(error[0])
end
end
def should_terminate?
reply = NSTerminateNow
if managed_object_context
if (managed_object_context.commitEditing)
error = Pointer.new_with_type(‘@’)
if (managed_object_context.hasChanges and !managed_object_context.save(error))
if NSApplication.sharedApplication.presentError(error[0])
reply = NSTerminateCancel
else
alertReturn = NSRunAlertPanel(nil, "Could not save changes while quitting. Quit anyway?",
"Quit anyway", "Cancel", nil)
if (alertReturn == NSAlertAlternateReturn)
reply = NSTerminateCancel
end
end
end
else
reply = NSTerminateCancel
end
end
reply
end
end
There isn’t anything special here. This is, basically, the Obj-C Core Data template ported to Ruby.
class ManagedObject
def self.create(vals={})
name = self.to_s.gsub(/^.*::/, ”)
obj = NSEntityDescription.insertNewObjectForEntityForName(name,
inManagedObjectContext:NSApp.delegate.managed_object_context)
entity = NSEntityDescription.entityForName(name,
inManagedObjectContext:NSApp.delegate.managed_object_context)
map_attributes(obj, entity.attributesByName)
map_relationships(obj, entity.relationshipsByName)
vals.each_pair do |key, value|
obj.send("#{key}=", value)
end
obj
end
def self.map_attributes(obj, attributes_by_name)
attributes_by_name.each_pair do |attribute, value|
obj.instance_eval %[
def #{attribute}=(val)
setValue(val, forKey:"#{attribute}")
end
def #{attribute}
valueForKey("#{attribute}")
end
]
if value.attributeType == NSBooleanAttributeType
obj.instance_eval %[
def #{attribute}?
send("#{attribute}".to_sym).boolValue
end
]
end
obj.send("#{attribute}=".to_sym, value.defaultValue)
end
end
def self.map_relationships(obj, relationships_by_name)
relationships_by_name.each_pair do |relationship, value|
if value.isToMany
obj.instance_eval %[
def #{relationship}
willAccessValueForKey("#{relationship}")
v = primitiveValueForKey("#{relationship}")
didAccessValueForKey("#{relationship}")
v
end
]
else
obj.instance_eval %[
def #{relationship}
valueForKey("#{relationship}")
end
def #{relationship}=(val)
setValue(val, forKey:"#{relationship}")
end
]
end
end
end
end
ManagedObject is a little more interesting. I didn’t want to have to use setValue:forKey:, valueForKey: and other NSManagedObject methods to work with my model. What ManagedObject does is create a new entity with NSEntityDescription.insertNewObjectForEntityForName:inManagedObjectContext: and then attach methods for the relationships and attributes of that entity.
We use NSEntityDescription.entityForName:inManagedObjectContext: to get the information on the entity. This allows us to call entity.attributesByName and entity.relationshipsByName. The last thing self.create does is take any provided options and send them to the appropriate keys. This allows us to do Author.create(:name => "dj2") instead of needing multiple lines.
The methods are created in self.map_attributes and self.map_relationships. For each attribute we attach an attribute= and attribute method. If the attribute is a boolean we also create an attribute? method. If the attribute assigns a default value we’ll send the default after the methods are added.
self.map_relationships works in a similar fashion if the relation ship isn’t to-many. If it is to-many we only create an attribute method. Currently you’ll need to use the NSSet methods to access the values in the to-many set.
The relationship code needs a bit more work as it won’t try to lookup a relationship, just blindly create a new row. I need to decide if I want to build that into create or add a find method to lookup the entities.
I’ve been storing my models in a lib/models directory. For this all we create three, almost identical, models. author.rb, post.rb and tag.rb
end
class Post < ManagedObject
end
class Tag < ManagedObject
end
The last changes are to application.rb. We need to require lib/core_data, lib/managed_object and our models.
require file
end
Then, inside the application class we include CoreData and we're good to go. You can then do something similar to:
p.title = "HotData"
p.pub_date = NSDate.date
p.content = "Playing with Core Data from HotCocoa"
p.author = Author.create(:name => 'dj2')
p.tags.addObject(Tag.create(:name => "Core Data"))
p.tags.addObject(Tag.create(:name => "HotCocoa"))
p.published = true if !p.published?
save
Which will create a ~/Library/Application Support/blog/Blog.xml file with your new post inside.
I've uploaded a copy of my source if you want to take a look.
Questions? Comments? Let'em rip.
July 31st, 2009 at 02:21
Does this work with MacRuby 0.4?
July 31st, 2009 at 08:59
It should. I created it using 0.4.