Making the demo app
We’re going to actually show the problem now instead of discussing it more. We’re going to demonstrate it by setting up an extremely simple forum-esque piece of software. This is going to talk about using some rails features. If you want to work the examples, the easiest way would be to actually create a full rails app and just use script/console to play with the models.
Our forum is pretty much the simplest forum you’ve ever heard of: it has topics and posts. I guess you can argue that’s the main part, but I stress that it’s the only part. A topic has a title and a creation date. We’ll also give it a state so you could, say, lock a topic or hide a topic. A post has some content, an author, and belongs to a topic. We’ll again give it a state, though I can only think to make it visible, invisible, or possibly flagged (for moderator review or something). So you can follow along, I’ll post pertinent code. Here’s a migration to create such a setup (I apologize now for poor formatting; I’m actually doing this all in the browser since I don’t have Rails set up on this computer):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | class CreateInitialSchema < ActiveRecord::Migration def self.up create_table :topics, :force => true do |t| t.column :title, :string t.column :created_on, :datetime t.column :state, :string, :default => 'visible' end create_table :posts, :force => true do |t| t.column :content, :text t.column :author, :string t.column :topic_id, :integer t.column :created_on, :datetime t.column :state, :string, :default => 'visible' end end def self.down drop_table :posts drop_table :topics end end |
We need to actually create these models. (For easier copy and pasting, I’ll post full listings for the files every time). We’re just making the models right now, so let’s set the basic models up. We’ll start with the very straight forward topic class that will just verify we don’t get multiple topics with the same name:
1 2 3 4 | def Topic < ActiveRecord::Base has_many :posts validates_uniqueness_of :title end |
At this point, that’s pretty straight forward. Next is for posts which to encourage variety will have a similar uniqueness validation on the content itself in the scope of the topic (so no two people can respond to a topic the same way:
1 2 3 4 | def Post < ActiveRecord::Base belongs_to :topic validates_uniqueness_of :content, :scope => :topic_id end |
If you want to go play with your new models, they should be functional at this point. They really aren’t that interesting though. What we can do to make them more interesting is to use the state_machine plugin I mentioned earlier. After all, there are certain rules that we can easily implement as a state machine on both our topics and posts.
For those of you who are not familiar with the concept of a state machine, it is a device that is described by states (think standing or sitting) and transitions between these (such as a transition called “stand up” that would take a human from sitting to standing). There are restrictions on what transitions can happen when though. It can be argued that standing up when already standing does nothing, but jumping up and down is something that should only be done when standing. So we can say you can transition from sitting to standing by stand up, from standing to sitting by sit down, and from standing to jumping by jump. This is a simple example of a silly state machine (that at this point means that once you start jumping you can’t stop…).
For our needs, we will start by describing the states of each object. Topics can either be visible (kind of a normal or default state), locked (no changing the topic or the posts in it), or hidden (doesn’t show up for visitors). Posts can be visible (again, normal and default), flagged (invisible to normal users but visible to moderators), or hidden (invisible). A topic needs to have at least one post in it to exist and at least one visible post to be visible itself. Also, posts can only be modified if the topic isn’t locked. Posts for a topic also must be hidden if a topic is hidden.
All these rules could be annoying to implement in models by default. state_machine makes it very easy to do though. We could alternatively use callbacks or just custom methods to simulate the same situation. Let’s start by creating the state machine for topics. We’ll need an extra method for dealing with hiding all the posts for the topic which we’ll define now also. (In order to make this work, you’ll need the state_machine plugin. You can get it by running script/plugin install git://github.com/pluginaweek/state_machine.git from your rails directory root).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | def Topic < ActiveRecord::Base has_many :posts validates_uniqueness_of :title state_machine :state, :initial => :visible do after_transition :on => :hide, :do => :hide_posts after_transition :on => :show, :do => :show_posts event :lock do transition :visible => :locked end event :unlock do transition :locked => :visible end event :hide transition any => :hidden end event :show transition :hidden => :visible end end protected def hide_posts this.posts.with_state('visible').each do { |post| post.hide_for_topic } end def show_posts this.posts.with_state('hidden_for_topic').each do { |post| post.unhide_for_topic } end end |
Events are the transitions we are looking for. Since we don’t need to define anything special for the states, we allow just the transition itself (which will update the :state property) to do the work. We did need to add a couple after_transition calls so that we can update the posts appropriately. It is important to notice that the each calls come on a scoped set of the posts, so we are only trying to ever transition validly (if you look at the transitions, they are :start_state => :end_state; since they all have one start state, starting from another will raise an exception stating “Invalid Transition”). Speaking of posts, we should go and add those methods we defined. But those aren’t (exactly) methods! Those are actually transitions. We’ll have those in our state machine also. Speaking of which, let’s define the state machine and update our Post model.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | def Post < ActiveRecord::Base belongs_to :topic validates_uniqueness_of :content, :scope => :topic_id after_destroy { |post| post.topic.destroy! if post.topic.posts.count < 1 } state_machine :state, :initial => :visible do # add some notification to admin after a post is flagged? after_transition :on => :hide, :do => :consider_hiding_topic event :flag do transition :visible => :flagged end event :approve do transition :flagged => :visible end event :hide do transition :visible => :hidden end event :show do transition :hidden => :visible end event :hide_for_topic transition :visible => :hidden_for_topic end event :show_for_topic transition :hidden_for_topic => :visible end end protected def consider_hiding_topic self.topic.hide! if self.topic.posts.with_state('visible').count < 1 end end |
All our rules are in place! You should be able to play with your models now if you want via irb or any other means you’d like.