Monday, November 24, 2008

Using ActiveRecord with forms but without a table

Introduction


From time to time I find myself implementing a story needing the nice validation features that
come with ActiveRecord and being able to display input errors in a simple way in the views.
I also want to implement the story in a RESTful way (even if this example should probably be
regarded as RESTlike).

But what I don't want is a table in the database.

Implementing a User-Changes-Password-story not long ago was such an occasion and I decided to clean out the domain specifics and post the way it was done and also publish the active_record_tableless plugin I use.

There were a number of good reasons not to use the existing User model directly, but that's long story. As this example is stripped of all the calls to legacy system it may seem like a strange way to do this but I hope you can follow along and understand how you can use active_record_tableless plugin in your own projects.
User story

User wanting to change password submits email address.

After email validation, the user enters passwords as listed below and submits to complete the change.

  • old password

  • new password

  • confirmation of the new password.



Implementation


Setup


You can download the complete source code from here, but I basically created a new rails app and generated a scaffold for a PasswordUpdate like this:
script/generate scaffold --skip-migration PasswordUpdate email:string \
password:string new_password:string new_password_confirmation:string

To be able to use a model without backing it with a database table I use the active_record_tableless plugin:
script/plugin install git://github.com/robinsp/active_record_tableless.git

Controller


We wont need some of the actions created by the scaffold generator so index and destroy actions were deleted. The generated controller was also cleansed from code returning xml.

After implementing the things we need for the user story implementation the controller looks like this:

class PasswordUpdatesController < ApplicationController

def new
@password_update = PasswordUpdate.new
end

def edit
@password_update = PasswordUpdate.find( params[:id] )
end

def update
@password_update = PasswordUpdate.find(params[:id])

if @password_update.update_attributes( params[:password_update] )
redirect_to '/'
else
render :action => "edit"
end
end

def create
@password_update = PasswordUpdate.new(
params[:password_update].merge(:password_required => false ) )

if @password_update.save
@password_update.exists!( @password_update.email_as_id )
redirect_to edit_password_update_url( @password_update )
else
render :action => "new"
end
end

end

Fairly standard stuff going on in the new, edit and update actions (lines 3-19) but what's going on in create?

Our user story states that a valid email address is entered into a form and submitted. Our new action renders this form with an single text_field and a button that submits to the create action. As we don't care about the passwords in this stage, their validations are disabled by setting the :password_required attribute to false (line 23). We'll look closer at this when discussing the model.

If there was something wrong with the submitted email addres the new action is rendered and the errors are displayed to the user (using the standard error_messages form helper).
However, if everything is ok and @password_update.save returns true (line 25) we want to behave well and redirect from the successful post.

As our model doesn't have a persistent id (because it doesn't have a database table) we fake this using the exists! method, provided by active_record_tableless plugin (line 26) and as our model object now has and an id, the redirect using a url helper on line 27 will work fine.

Model


PasswordUpdate model is implemented like this:

class PasswordUpdate < ActiveRecord::Base
tableless :columns => [
[:email, :string],
[:password, :string],
[:new_password, :string]
]

attr_accessor :password_required

validates_presence_of :email

validates_presence_of :password,
:new_password,
:new_password_confirmation,
:if => :password_required?

validates_confirmation_of :new_password,
:if => :password_required?

# Defaults to true
def password_required?
self.password_required.nil? ? true : self.password_required
end

def email_as_id
raise "No email set" unless self.email
PasswordUpdate.encode(self.email)
end

class << self
def find_by_id(id)
raise "No id argument" unless id
p = PasswordUpdate.new(:email => decode(id))
p.exists!(p.email_as_id)
return p
end
alias_method :find, :find_by_id

def encode( str )
str.sub("\.", "-")
end

def decode( str )
str.sub("-", ".")
end
end

end

Line 2-6 set up the attributes we need. To control whether validation should be done on passwords we need an attribute for this (line 9). We saw this in the create action earlier.

The validations declared on lines 10-18 are fairly straight forward except that they don't check the passwords if our password_required attribute is false. (Rigorous validation of email format and such has been omitted in this example code.)

On lines 25-28 a util method is provided to convert the email address into a valid string to use in a url.

Lines 30-37 implements finders to fake that our model actually exists as a normal model would when returned from find. The bare minimum for an existing model is that it should have an id and that new_record? should return false. The active_record_tableless plugin takes care of this for us when we call the exists! method (line 34).

Resources


active_record_tableless plugin on github

The complete Rails 2.1 project that the example above describes is here.

About 2 years ago Jonathan Viney wrote ActiveRecord::Base Without Table. It doesn't seem to be maintained and doesn't support the exists! method described above. It also has a different way of integrating into the models.
Also, if you want to use "ActiveRecord::Base Without Table" with Rails 2.x you need to install it from activerecord-basewithouttable-for-rails-2 where Clinton R. Nixon has graciously been hosting it for some time.

6 comments:

  1. Can your active_record_tableless plugin be used in a model which _does_ have a table, as a way of adding additional 'typed' virtual attributes?

    I often find myself creating virtual attributes to allow information through from forms via update_attributes without polluting the controller to get more complex behaviour dealt with by the model. It'd be nice to get some of the automatic type casting behaviour that occurs with 'proper' attributes for free, in addtion to possibly solving the odd problem where some form helper or other won't work because it expects to be able to interrogate the attribute to work properly.

    ReplyDelete
  2. @Simon

    Simon,
    I'm afraid this is not possible with this plugin.

    I agree that this would be a nice feature to have and I'm surprised I haven't stumbled on this need myself.
    Please keep me posted if you find something with this functionality. I'll update this blog post if I stumble onto something, or find a way to implement it.

    ReplyDelete
  3. My server doesn't start - it's complaining about something called Restflection. I've tried installing a plugin with the same name, but ART seems to look in its lib directory for it.

    Any solutions for this? I'm running 2.2.2 btw.

    ReplyDelete
  4. Actually scrap that; it seems that the plugin was installed totally incorrectly. I've got files from other plugins! Looking into it now.

    ReplyDelete
  5. I noticed that the tableless method absolutely requires at least one column to be defined when it's called. This is okay for normal cases but there are times when I wish to declare a model tableless and add columns using the column method.

    It's easy enough to omit the check for no columns defined, I was just wondering why it's in there in the first place. Surely the vacuous case of a columnless tableless model should be supported?

    ReplyDelete
  6. @Shak
    To be honest, a columnless model never struck my mind but I'm sure there are valid cases for this.

    If you haven't already, feel free to fork the git repo and implement this feature.

    ReplyDelete