Functional Data Flows (.select.map) to Create Dependent Records in Ruby/Rails
You may have seen code in the past that looks something like this:
user = User.build(user_params)
if user.save
profile = Profile.build(user: user)
if profile.save
Yearbook.build(profile: profile, anniversary: Date.now)
end
end
Lets say a Yearbook
is an object that keeps track of anniversaries and/or milestones. A user has one profile and can have many yearbooks. No need to worry about the particulars of the models or why the sequence is the way it is. Just take it at a high level as a situation where record creation and updates are dependent on other record creations and updates.
We have a sequence of record creations and updates, where:
- the second record (
Profile
) is dependent on the first (User
) - the third record (
Yearbook
) is dependent on the second (Profile
)
We assign the variables because it’s a fairly nice thing to do for readability and common in the Ruby and Rails world. Do the variables ever actually…vary?
Instead of using explicit, nestedif
statements and assigning multiple variables to memory (that never actually change), let’s construct a functional data flow that leverages (1) a declarative style (with if
statements operating in a private interface) and (2) automatically cleaned up resources with blocks (i.e., anonymous functions) and closures.
[User.build(user_params)].select(&:save)
.map { |user| Profile.build(user: user) }
.select(&:save)
.map { |profile| Yearbook.build(profile: profile,
anniversary: Date.now) }
.select(&:save)
.first
On the first line, we wrap a built User
record in a singleton array (i.e., an array of one element). “Lifting” a single value into an array is like putting a value into a box, a box that has extra functionality. By putting a value (a record) into a context (an array), we get to use Ruby’s lovely Enumerable
module to handle control flow and data transformations in a more declarative way.
On the singleton array, we call Enumerable#select
using &:save
as our predicate function for select
(we are in ActiveRecord land, where .save
returns true
if the record is persisted to the database and false
if not). If this is strange syntax to you:
[1,2].select(&:even?) == [1,2].select { |number| number.even? }
This is sugar for a common pattern of calling a single method with no arguments on each element in an array.
The select
method will only return objects that have a “truthy” return value (i.e., not nil
or false
) from the predicate function. In this case, if .save
returns false
, the returned array will be empty []
. All subsequent .map
and .select
calls are no-ops. If the .save
is successful and returns true
, the returned array contains the saved User
record. On to the second line!
Assuming .map
is called on an array with a successfully saved User
record, that record is passed to the .map
block and used to build a Profile
record. The returned array now contains a built Profile
record instead of a saved User
record.
On the third line, we use the same .save
predicate sugar. Again, if the save is successful, the array retains its Profile
record. If the save is not successful, the returned array is empty, and all subsequent calls are no-ops.
On the fourth line, we use the Profile
record to build a Yearbook
record.
On the fifth line, we filter for a successful save to the database.
Lastly, on the sixth line, we return the array’s first (and only) element. There are two return cases:
true
: All of the record creations were successful.nil
: The creation ofUser
,Profile
, and/orYearbook
was unsuccessful. Because[].first == nil
, at some point in the flow.save
failed and returnedfalse
, leaving us[]
for the rest of the flow.
Stepping Through Scenarios
*these snippets will not execute in Ruby because the comments break the method chain
- All successful creations
[User.build(user_params)].select(&:save)
# [<User id: 1 ...>]
.map { |user| Profile.build(user: user) }
# [<Profile id: nil ...>]
.select(&:save)
# [<Profile id: 1 ...>]
.map { |profile| Yearbook.build(profile: profile,
anniversary: Date.now) }
# [<Yearbook id: nil ...>]
.select(&:save)
# [<Yearbook id: 1...>]
.first # => <Yearbook id: 1 ...>
2. Failed Yearbook
creation
[User.build(user_params)].select(&:save)
# [<User id: 1 ...>]
.map { |user| Profile.build(user: user) }
# [<Profile id: nil ...>]
.select(&:save)
# [<Profile id: 1 ...>]
.map { |profile| Yearbook.build(profile: profile,
anniversary: Date.now) }
# [<Yearbook id: nil ...>]
.select(&:save)
# []
.first # => nil
3. Failed Profile
creation
[User.build(user_params)].select(&:save)
# [<User id: 1 ...>]
.map { |user| Profile.build(user: user) }
# [<Profile id: nil ...>]
.select(&:save)
# []
.map { |profile| Yearbook.build(profile: profile,
anniversary: Date.now) }
# []
.select(&:save)
# []
.first # => nil
4. Failed User
creation
[User.build(user_params)]
# [<User id: nil ...>]
.select(&:save)
# []
.map { |user| Profile.build(user: user) }
# []
.select(&:save)
# []
.map { |profile| Yearbook.build(profile: profile,
anniversary: Date.now) }
# []
.select(&:save)
# []
.first # => nil
With each .select(&:save)
we are producing a side-effect, so this flow is not 100% functionally pure, but it does turn the problem into a simple series of inputs and outputs. Additionally, the side-effects are nicely separated from transformations. You can see that in this flow, map
takes care of transformations, while select
takes care of mutations. This workflow allows us to isolate our focus on one type of problem at a time and think with more clarity and confidence.
By wrapping an object in an array, we were able to make a single, self-contained flow using Enumerable#map
and Enumerable#select
. This removed explicit uses of if
statements (that’s being used by #select
) and moved variable assignment from an outer to a more private scope. It also illuminated a nice structural pattern that was harder to see with nested if
statements. Overall, we used native Ruby features to write a lean, declarative, functional solution that subscribes more to an interface than an implementation