An Interest In:
Web News this Week
- March 27, 2024
- March 26, 2024
- March 25, 2024
- March 24, 2024
- March 23, 2024
- March 22, 2024
- March 21, 2024
Intelligent ActiveRecord Models
ActiveRecord models in Rails already do a lot of heavy lifting in terms of database access and model relationships, but with a bit of work they can do more things automatically. Let’s find out how!
Step 1 - Create a Base Rails App
This idea works for any sort of ActiveRecord project but since Rails is most common, we’ll be using that for our example app. The app we’ll be using has lots of Users, each of whom does things to a number of Projects .
If you’ve not created a Rails app before then read this tutorial first. Otherwise fire up the old console and type rails new example_app
to create the app and then change directory to inside your new app with cd example_app
.
Step 2 - Create Your Models and Relationships
First we generate the user that will own:
rails generate scaffold User name:text email:string password_hash:text
Obviously in the real world, we’d probably have a few more fields but it’ll do for now. Let’s generate our project model:
rails generate scaffold Project name:text started_at:datetime started_by_id:integer completed_at:datetime completed_by_id:integer
We then edit the generated project.rb
file to describe the relationship between users and projects:
class Project < ActiveRecord::Base belongs_to :starter, :class_name =>"User", :foreign_key =>"started_by_id" belongs_to :completer, :class_name =>"User", :foreign_key =>"completed_by_id" end
and the reverse relationship in user.rb
:
class User < ActiveRecord::Base has_many :started_projects, :foreign_key =>"started_by_id" has_many :completed_projects, :foreign_key =>"completed_by_id" end
Then run a quick rake db:migrate
and we’re ready to start getting intelligent with these models. If only getting relationships with models was as easy in the real world! Now if you’ve ever used the Rails framework before, you’ve probably learnt nothing so far, yet…
Step 3 - Faux Attributes Are Cooler Than Faux Leather
The first thing we’re going to do is use some auto generating fields. You’ll have noticed that when we created the model, we created a password hash and not a password field. We’re going to create a faux attribute for a password that will convert it to a hash if it’s present.
So in your model, we’ll add a definition for this new password field.
def password={new_password) write_attribute(:password_hash, SHA1::hexdigest(new_password)) end def password "" end
This means whenever a user model has user.password = 'supersecretpassword'
We only store a hash against the user so we’re not giving out the passwords without a bit of a fight.
The second method means we return something for forms to use
We also need to ensure we have the Sha1 encryption library loaded so go and add require 'sha1'
to your application.rb
file after line 40: config.filter_parameters += [:password]
.
As we’ve changed the app at the configuration level, reload it with a quick touch tmp/restart.txt
in your console.
Now let’s change the default form to use this instead of password_hash
. Open up _form.html.erb
in the app/models/users folder
<div class="field"> <%= f.label :password_hash %><br /> <%= f.text_area :password_hash %> </div>
becomes
<div> <%= f.label :password %><br/> <%= f.text_field :password %> </div>
We’ll make it an actual password field when we’re happy with it.
Now load https://localhost/users
and have a play with adding users. Should look a bit like the one below, great isn’t it!
Wait, what’s that? It overwrites your password hash every time you edit a user? Let’s fix that.
Open up user.rb
again and change it like so:
write_attribute(:password_hash, SHA1::hexdigest(new_password)) if new_password.present?
Now only when you supply a password does the field get updated.
Step 4 - Automatic Data Guarantees Accuracy or Your Money Back
The last section was all about changing the data that your model gets but what about adding more information based on things already known without having to specify them? Let’s have a look at that with the project model. Start by having a look at https://localhost/projects.
Make the following changes quickly.
*app/controllers/projects_controler.rb* line 24
# GET /projects/new # GET /projects/new.json def new @project = Project.new @users = ["--",nil] + User.all.collect { |u| [u.name,u.id] } respond_to do |format| format.html # new.html.erb format.json { render :json =>@project } end end # GET /projects/1/edit def edit @project = Project.find(params[:id]) @users = ["--",nil] + User.all.collect { |u| [u.name,u.id] } end
*app/views/projects/_form.html.erb* line 24
<%= f.select :started_by_id, @users %>
*app/views/projects/_form.html.erb* line 24
<%= f.select :completed_by , @users%>
In MVC frameworks, the roles are clearly defined. Models represent the data. Views display the data. Controllers get data and pass them to the view.
Who Enjoys Filling Out Date/time Fields?
We now have a full functioning form but it bugs me I have to set the start_at
time manually. I’d like to have it set when I assign a started_by
user. We could put it in the controller but if you’ve ever heard the phrase “fat models, skinny controllers” you’ll know this makes for bad code. If we do this in the model, it’ll work anywhere we set a the starter or completer. Let’s do that.
First edit app/models/project.rb
adding the following method
def started_by=(user) if(user.present?) user = user.id if user.class == User write_attribute(:started_by_id,user) write_attribute(:started_at,Time.now) end end
This code checks that something has actually been passed, then if it’s a user it gets its ID and finally writes both the user *and* the time it happened, holy smokes! Let’s add the same for the completed_by field.
def completed_by=(user) if(user.present?) user = user.id if user.class == User write_attribute(:completed_by_id,user) write_attribute(:started_at,Time.now) end end
Now edit the form view so we don’t have those time selects. In app/views/projects/_form.html.erb
remove lines 26-29 and 18-21.
Open up https://localhost/projects
and have a go!
Spot the Deliberate Mistake
Whoooops! Someone (I’ll take the heat since it’s my code) cut and paste and forgot to change the :started_at
to :completed_at
in the second largely identical (hint) attribute method. No biggie, change that and everything is go… right?
Step 5 - Help Your Future Self by Making Additions Easier
So apart from a little cut-and-paste confusion I think we did pretty well but that slip up and the code around it bothers me a little. Why? Well, let’s have a think:
- It’s cut and paste duplication: DRY (Don’t repeat yourself) is a pretty good principle.
- What if someone wants to add another
somethingd_at
andsomethingd_by
to our project like sayauthorised_at
andauthorised_by
> - I can imagine quite a few of these fields being added.
Lo and behold along comes a pointy haired boss and asks for, {drumroll}, authorised_at/by field and a suggested_at/by field! Right then let’s get those cut and paste fingers ready then… or is there a better way
The Scary Art of Meta-progamming!
That’s right! The holy grail, the scary stuff your mothers warned you about. It seems complicated but actually can be pretty simple. Especially what we’re going to attempt. We’re going to take an array of the names of stages we have and then auto build these methods on the fly. Excited? Great.
Of course, we’ll need to add the fields so let’s add a migration rails generate migration additional_workflow_stages
and add those fields inside the newly generated db/migrate/TODAYSTIMESTAMP_additional_workflow_stages.rb
.
class AdditionalWorkflowStages < ActiveRecord::Migration def up add_column :projects, :authorised_by_id, :integer add_column :projects, :authorised_at, :timestamp add_column :projects, :suggested_by_id, :integer add_column :projects, :suggested_at, :timestamp end def down remove_column :projects, :authorised_by_id remove_column :projects, :authorised_at remove_column :projects, :suggested_by_id remove_column :projects, :suggested_at endend
Migrate your database with rake db:migrate
, and replace the projects class with:
class Project < ActiveRecord::Base # belongs_to :starter, :class_name =>"User" # def started_by=(user) # if(user.present?) # user = user.id if user.class == User # write_attribute(:started_by_id,user) # write_attribute(:started_at,Time.now) # end # end # # def started_by # read_attribute(:completed_by_id) # end end
I’ve left the started_by
in there so you can see how the code was before.
[:starte,:complete,:authorise,:suggeste].each do |arg| ..MORE.. end
Nice and gentle, goes through the names(ish) of the methods we want to create
[:starte,:complete,:authorise,:suggeste].each do |arg| attr_by = "#{arg}d_by_id".to_sym attr_at = "#{arg}d_at".to_sym object_method_name = "#{arg}r".to_sym ...MORE... end
For each of those names, we work out the two model attributes we’re setting e.g started_by_id
and started_at
and the name of the association e.g. starter
[:starte,:complete,:authorise,:suggeste].each do |arg| attr_by = "#{arg}d_by_id".to_sym attr_at = "#{arg}d_at".to_sym object_method_name = "#{arg}r".to_sym belongs_to object_method_name, :class_name =>"User", :foreign_key =>attr_by end
Pretty familiar. This is actually a Rails bit of metaprogramming already that defines a bunch of methods.
[:starte,:complete,:authorise,:suggeste].each do |arg| attr_by = "#{arg}d_by_id".to_sym attr_at = "#{arg}d_at".to_sym object_method_name = "#{arg}r".to_sym belongs_to object_method_name, :class_name =>"User", :foreign_key =>attr_by get_method_name = "#{arg}d_by".to_sym define_method(get_method_name) { read_attribute(attr_by) } end
Ok, some real meta programming now that calculates the ‘get method’ name e.g. started_by and then creates a method just like we do when we write def method
but in a different form.
[:starte,:complete,:authorise,:suggeste].each do |arg| attr_by = "#{arg}d_by_id".to_sym attr_at = "#{arg}d_at".to_sym object_method_name = "#{arg}r".to_sym belongs_to object_method_name, :class_name =>"User", :foreign_key =>attr_by get_method_name = "#{arg}d_by".to_sym define_method(get_method_name) { read_attribute(attr_by) } set_method_name = "#{arg}d_by=".to_sym define_method(set_method_name) do |user| if user.present? user = user.id if user.class == User write_attribute(attr_by,user) write_attribute(attr_at,Time.now) end end end
A little bit more complicated now. We do the same as before but this is the set method name. We define that method, using define(method_name) do |param| end
rather than def method_name=(param)
That wasn’t so bad, was it?
Try it Out in the Form
Let’s see if we can still edit projects as before. Turns out we can! So we’ll add the additional fields to the form and hey presto!
app/views/project/_form.html.erb
line 20
<div class="field"> <%= f.label :suggested_by %><br/> <%= f.select :suggested_by, @users %> </div> <div class="field"> <%= f.label :authorised_by %><br/> <%= f.select :authorised_by, @users %> </div>
And to the show view so we can see it working.
*app/views-project/show.html.erb* line 8
<p> <b>Suggested at:</b> <%= @project.suggested_at %> </p> <p> <b>Suggested by:</b> <%= @project.suggested_by_id %> </p> <p> <b>Authorised at:</b> <%= @project.authorised_at %> </p> <p> <b>Authorised by:</b> <%= @project.authorised_by_id %> </p>
Have another play with https://localhost/projects
and you can see we have a winner. No need to fear if someone asks for another workflow step, just add the migration for the database and put it in the array of method and it gets created. Time for a rest? Just two more things.
Step 6 - Automate the Automation
That array of methods just seems very useful to me. Could we do more with it?
First let’s make the list of method names a constant so we can access it from outside.
WORKFLOW_METHODS = [:starte,:complete,:authorise,:suggeste] WORKFLOW_METHODS.each do |arg|....
Now we can use them to auto create form and views.
So open up the _form.html.erb
for projects and let’s try it by replacing lines 19 -37 with the mere snippet below:
<% Project::WORKFLOW_METHODS.each do |workflow| %> <div class="field"> <%= f.label "#{workflow}d_by" %><br/> <%= f.select "#{workflow}d_by", @users %> </div> <% end %>
But app/views-project/show.html.erb
is where the real magic is:
<p id="notice"><%= notice %></p> <p> <b>Name:</b>: <%= @project.name %> </p> <% Project::WORKFLOW_METHODS.each do |workflow| at_method = "#{workflow}d_at" by_method = "#{workflow}d_by_id" who_method = "#{workflow}r" %> <p> <b><%= at_method.humanize %>:</b>: <%= @project.send(at_method) %> </p> <p> <b><%= who_method.humanize %>:</b>: <%= @project.send(who_method) %> </p> <p> <b><%= by_method.humanize %>:</b>: <%= @project.send(by_method) %> </p> <% end %> <%= link_to 'Edit', edit_project_path(@project) %> | <%= link_to 'Back', projects_path %>
This should be pretty clear although if you’ve not seen send()
before, it’s another way to call a method. So object.send("name_of_method")
is the same as object.name_of_method
.
Final Sprint
Almost done, but I’ve noticed two bugs: one is formatting and the other is a bit more serious.
The first is that while I view a project, the whole method is showing a ugly ruby object output. Rather than add a method on the end like:
@project.send(who_method).name
Let’s modify User
to have a to_s
method. Keep things in the model if you can, add this to the top of the user.rb
, and maybe do the same for project.rb
as well. It always makes sense to have a default representation for a model as a string:
def to_s name end
Feels a bit mundane writing methods the easy way now, eh? No? Oh ok. Anyway on to more serious things.
An Actual Bug
When we update a project because we send all of the workflow stages that have been assigned previously, all our time stamps are mixed up. Fortunately, because all our code is in one place, a single change will fix them all.
define_method(set_method_name) do |user| if user.present? user = user.id if user.class == User # ADDITION HERE # This ensures it's changed from the stored value before setting it if read_attribute(attr_by).to_i != user.to_i write_attribute(attr_by,user) write_attribute(attr_at,Time.now) end end end
Conclusion
What have we learnt?
- Adding functionality to the model can seriously improve the rest of you code
- Meta programming isn’t impossible
- Suggesting a project might get logged
- Writing smart in the first place means less work later
- No-one enjoys cutting, pasting and editing and it causes bugs
- Smart Models are sexy in all walks of life
The one thing that bugs me now is those workflow methods could surely be generated from the database&elipsis; Maybe an exercise for the reader! Thank you so much for reading and let me know if you have any questions.
Original Link: http://feedproxy.google.com/~r/nettuts/~3/Mfp_5xVl-fA/
TutsPlus - Code
Tuts+ is a site aimed at web developers and designers offering tutorials and articles on technologies, skills and techniques to improve how you design and build websites.More About this Source Visit TutsPlus - Code