Background
I've been working on a Rails 3.2 project for the
last several months. While it's been a blast to learn a new technology
stack (I've spent most of my career working with .NET), there have
definitely been some bumps in the road. In particular, I've spent the
last couple weeks working on persisting a model that has a parent-child
relationship. The bulk of that time was spent poking around the
inter-webs, looking for tips and tricks to accomplish my goal. During my
inter-web search, I couldn't find a single post (or series of posts)
that had a complete example or tutorial on how to persist a
model that has a parent/child relationship. My Google-fu might not be as
good as some other developers out there, but I searched pretty hard.
What I found were bits and pieces scattered about (primarily on my
savior, Stackoverflow).
After a LOT of trial and error (mostly the latter), I finally have it
working. So I thought I'd collect my notes as a series of posts. FYI -
this started as one post, but got WAY too long. Even for me.
Objective/Goals
Reading
is real neat, and I do a whole lot of it. I tend to learn by doing, so
reading only gets me halfway there. To that end, we'll build a demo
application that ultimately addresses the following:
- Allows a user to save a model that has a parent/child relationship.
- The operation will allow us to save the parent object as well as insert/update/delete new child objects that are associated to the parent.
- Handles saving a model that has a many-to-many relationship (e.g. a User might belong to many Security Groups).
The Application
Most
demo apps I see deal with users, customers, security groups and the
like. That's all well and good, but I thought it'd be fun to work on a
demo app about something I like - Star Wars! My favorite aspect of Star
Wars is the ships, particularly the fighters. So, let's build a simple
database app that will allow us to maintain data about the fighters in
the Star Wars universe. The idea behind the demo application is fairly
simple: it's a Star Wars Starfighter Recognition Guide (albeit a VERY
simplified one).
Initially, the concept for our initial application is very simple:
- There are Ships.
- Ships have zero to many Pilots (i.e. characters/personalities who fly the type of ship being viewed).
For example: Garven Dreis, Wedge Antilles, and Biggs Darklighter all fly the T-65 X-Wing.
This application will allow a user to create/edit/delete a Ship
. While creating a Ship
, a user has the option of creating records for the Pilots
that fly the Ship
being created. When editing an existing Ship
, a user has the ability to:
- Create a new
Pilot
. - Edit an existing
Pilot
. - Remove a
Pilot
from the list.
A
user can conduct any number of the changes cited above prior to saving.
When the form is submitted, we expect Rails/ActiveRecord will do the
right thing. It will:
- Destroy the
Pilots
that were removed. - Add the
Pilots
that were created. - Update the
Pilots
that were changed. - Update the
Ship
attributes that were changed.
NOTE: This series of posts assumes some basic knowledge of Ruby and Rails. I highly recommend going through the Rails Tutorial - it's a great introduction to the technology.
Righty-o - let's get started.
The Model
For
starters, let's take a look at the domain model we'll be working with
for this demonstration. We'll keep things simple by having a
straightforward parent/child relationship:
Ship
: This will be our "parent" object - it represents some basic information about a starfighter in the Star Wars universe.Pilot
: This will be our "child" object - it represents a person who is rated to fly the starfighter.
Here's a picture of what our domain model we'll be working with (thanks, RubyMine!):
Moving on, let's take a look at the code for each object in our domain model:
app/models/ship.rb:
class Ship < ActiveRecord::Base
attr_accessible :armament, :crew, :has_astromech, :name, :speed
attr_accessible :pilots_attributes
has_many :pilots
accepts_nested_attributes_for :pilots,
:reject_if => lambda { |attrs| attrs.all? { |key, value| value.blank? } },
:allow_destroy => true
#I find your lack of validation disturbing...
end
I'd like to call your attention to the attr_accessible :pilots_attributes
- that'll be important later.
app/models/pilot.rb:
class Pilot < ActiveRecord::Base
belongs_to :ship
attr_accessible :call_sign, :first_name, :last_name, :ship_id
#I find your lack of validation disturbing...
end
The accepts_nested_attributes_for Method
According to the documentation, the accepts_nested_attributes_for
method "Defines an attributes writer for the specified association(s)." What does that mean, exactly? That means that our Ship
model can take in attributes for any of its associated Pilots
and update them. To use accepts_nested_attributes_for
, we point it at one of our associations. Right now, we only have one association - has_many :pilots
- so the choice is pretty easy. Armed with the accepts_nested_attributes_for :pilots
, we can:
- add new
Pilots
to our Ship
. - update our
Ship's
existing Pilots.
- there is a special attribute (
_destroy
) that will allow us to mark certain Pilots
to be deleted from our Ship
(more on this in a bit).
How accepts_nested_attributes_for Works
What's going on behind the scenes (as I understand it, anyway) is this: When you add an accepts_nested_attributes_for
method to your model, a writer is also added to your model. That writer will be named as follows [the name of your association]_attributes
. In our case, since we have a accepts_nested_attributes_for :pilots
method, the writer will be named pilots_attributes
(note the pluralization of Pilot
).
accepts_nested_attributes_for Options
Notice that our Ship
model also has an attr_accessible :pilots_attributes
accessor set up. This allows us to easily pass our Pilot
data to the pilots_attributes
writer that was created as a result of the accepts_nested_attributes_for
implementation. With the pilots_attributes
in our attr_accessible
list, we won't get the dreaded "Can't mass-assign" error.
NOTE: having your accepts_nested_attributes_for
writer available for mass-assignment might not be a good idea for your
real-life application. Take careful consideration when adding your
model's attributes to the attr_accessible
list.
Next, we have the :reject_if
option. This option allows us to specify a method (using a symbol or an anonymous method) to determine if a Pilot
record should be built. Whatever code is executed by the :reject_if
option should return true
or false
. In our case, we don't want to build a Pilot
record if all the attributes are empty. This will prevent ActiveRecord from saving blank/empty rows in our Pilots
table.
Finally, we have the :allow_destroy
option. This option can be set to true
or false
, and it does pretty much what it says. If we have :allow_destroy => true
(as we do in our case), ActiveRecord can delete the Pilots
that have their :_destroy
attribute set to true
.
You can check out the documentation and the available options for the accepts_nested_attributes_for
method here.
What a POST Looks Like From the UI
I
realize I'm skipping ahead a bit, 'cause we haven't even talked about
the UI. However, I thought it would be beneficial to see how the data
for nested objects (i.e. our Pilots
) is sent across the
wire. After watching a couple POSTs, it's really easy to see what's
going on with all the setup we did on our models above.
Scenario 1 - A User Creates a New Ship With No Pilots
If a user only creates a Ship
and no Pilots
(i.e. the ships#create
method is called on the ships_controller
), the data contained in the POST will look like this (taken and "prettified" a bit from Firebug):
&ship[name]=TIE+Fighter &ship[crew]=1 &ship[has_astromech]=0 &ship[speed]=100 &ship[armament]=2+laser+cannons
As you can see above, we're only passing attributes for our Ship
object. The controller will take these parameters and build a new Ship
object. Here's the SQL that was executed when the controller saved our new Ship
to the database:
INSERT INTO "ships" ("armament", "created_at", "crew", "has_astromech",
"name", "speed", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?)
[["armament", "2 laser cannons"], ["created_at", Tue, 15 Jan 2013
19:04:13 UTC +00:00], ["crew", 1], ["has_astromech", false], ["name",
"TIE Fighter"], ["speed", 100], ["updated_at", Tue, 15 Jan 2013 19:04:13
UTC +00:00]]
Scenario 2 - A User Creates a New Ship With a Couple Pilots
Now
our user is getting a little ambitious, but thanks to the setup we did
on our model we're ready. In this case, a user has created a new Ship
with a couple Pilots
associated. Shown below is a POST that creates a new Ship
with two Pilots
:
&ship[name]=TIE+Fighter &ship[crew]=1 &ship[has_astromech]=0 &ship[speed]=100 &ship[armament]=2+laser+cannons &ship[pilots_attributes][1358277455305][first_name]=Cive &ship[pilots_attributes][1358277455305][last_name]=Rashon &ship[pilots_attributes][1358277455305][call_sign]=Howlrunner &ship[pilots_attributes][1358277455305][_destroy]=false &ship[pilots_attributes][1358277658761][first_name]=Dodson &ship[pilots_attributes][1358277658761][last_name]=Makraven &ship[pilots_attributes][1358277658761][call_sign]=Night+Beast &ship[pilots_attributes][1358277658761][_destroy]=false
Let's take a look at the
pilots_attributes
parameters - they correspond to our
:pilots_attributes
accessor. Each set of
pilots_attributes
has four attributes grouped by a "unique id" of sorts (we'll see how
the unique id is generated in a future article). In our controller, when
we call
Ship.new(params[:ship])
, ActiveRecord can build the
Pilots
that correspond to the
Ship
in the
ships#create
method. That's because we have declared
attr_accessible :pilots_attributes
in our
Ship
model. We can also see the special
_destroy
attribute, which is set to
false
in both cases. That means we want to add these
Pilots
to our database.
For the sake of completeness, here's the create
method in the ships_controller
:
app/controllers/ships_controller.rb:
def create
@ship = Ship.new(params[:ship])
if @ship.save
redirect_to(ships_path, :notice => "The #{ @ship.name } ship has been saved successfully.")
else
render(:new, :error => @ship.errors)
end
end
Again, here's the SQL that was generated by ActiveRecord to save our Ship
and its associated Pilots
:
(0.1ms) begin transaction
SQL (0.4ms) INSERT INTO "ships" ("armament", "created_at", "crew",
"has_astromech", "name", "speed", "updated_at") VALUES (?, ?, ?, ?, ?,
?, ?) [["armament", "2 laser cannons"], ["created_at", Tue, 15 Jan 2013
19:21:33 UTC +00:00], ["crew", 1], ["has_astromech", false], ["name",
"TIE Fighter"], ["speed", 100], ["updated_at", Tue, 15 Jan 2013 19:21:33
UTC +00:00]]
SQL (0.7ms) INSERT INTO "pilots" ("call_sign",
"created_at", "first_name", "last_name", "ship_id",
"updated_at") VALUES (?, ?, ?, ?, ?, ?) [["call_sign", "Howlrunner"],
["created_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00], ["first_name",
"Cive"], ["last_name", "Rashon"], ["ship_id", 8],
["updated_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00]]
SQL (0.3ms) INSERT INTO "pilots" ("call_sign", "created_at",
"first_name", "last_name", "ship_id",
"updated_at") VALUES (?, ?, ?, ?, ?, ?) [["call_sign", "Night Beast"],
["created_at", Tue, 15 Jan 2013 19:21:33 UTC +00:00], ["first_name",
"Dodson"], ["last_name", "Makraven"], ["ship_id", 8], ["updated_at",
Tue, 15 Jan 2013 19:21:33 UTC +00:00]]
(2.2ms) commit transaction
Summary: Look at the Size of That Thing!
We've
barely impacted on the surface of our Starfighter Recognition Guide,
but we've managed to take a fairly detailed look at how to:
- Establish a parent/child relationship in our model.
- Set
up our "parent" model to accept data for any "child" objects, allowing
us to save the "parent" and all its "children" at once.
We've also
used the Force to look ahead at our UI and the data it will send to our
controller when the user submits the form. This peek into the future has
exposed some of the internal plumbing that goes on when Rails
builds/saves a model with a parent/child relationship.
In
our next installment, we'll take a good long look at the controller and
the UI helpers for our Starfighter Recognition Guide. We'll add some
screens that will allow our user to perform CRUD operations on our Ships
and Pilots
.
Stay tuned...