Fri, 2008 Aug 15
Wistle Part 1: A Simple Merb Application
So, I decided to create a blogging application. After all, Typo was pretty nice and I've quite happily used (and abused) Mephisto for some time. And of course, there's a thousand other options out there. But, over the last few years, I've developed a wish list, so:
- Store the actual articles in a source code repository, ideally Subversion (or maybe git, but I'm much more comfortable with Subversion).
- Store views in the same repo as the articles (or, at least, separate from the app itself. I don't have a particularly good reason for this, but point #6 will take care of that).
- Views in anything other than liquid. I mean, I just can't stand it. I understand it's purpose and it's great and all, but I wanna' write my view code in Ruby..or PHP..or VBA..or Lisp..or that programming language that's all in whitespace..or... Phhbbt!
- By default, set up for multiple sites hosted by a single app.
- Easy to add content filters, like Markdown and Textile, but also including my own (This is actually pretty easy in Mephisto, but again, point #6).
- I wanted a relatively simple but challenging project to do on my own, so I mostly made up 1-5 to justify it. Hey-o!
I picked Merb for the framework and DataMapper for the ORM, mostly because I've been experimenting with these lately. In addition, they feel more flexible than Rails for doing stuff like points one and two, and because I can't stand the font on the DataMapper site. Hello? WTF is that? "Font-that-looks-like-I- designed-it-in-MS-Paint? While fending off a horde of rabid chihauhas?" Seriously.
Oh, it's a "humanist sans-serif typeface". Good to know.
- -
Anywho, I thought I'd walk you [insert your name here] through the process, partly because it has some fun, not-normally-tutorial-stuff aspects, partly as yet another intro to the rapidly changing worlds of Merb and DataMapper. Mostly because I just felt like it.
I'm also going to try to do this in discrete stages, adding elements of the above requirements as I go. I think that will work okay. Be agiley and s---.
Repository note
I have set up a project on Google code at http://code.google.com/p/wistle/, for you to actually view the code. I'll also reference particular revisions. However, big note, there's some errors in the code, and some things changed because Merb and DataMapper are changing, and because I'm learning. So, things might not work for you, and what's in these articles may be different from what's in the repository. Hopefully, this won't be too big a problem, because--hopefully--these blog entries will explain enough to start you on the right path to figuring out what's wrong.
Here goes.
Generate the app
NB: If you're trying on Windows...good luck. By the way, if you use a Windows machine, for whatever reason, colinux can be your friend.
- Get the gems. See the respective sites (Merb, DataMapper).
merb-gen app wistle(No, I don't know why we're calling this app 'wistle'. It's what I happened to type.
Hopefully, that worked. If not, do some google searches; depending on the day, everything may work or fail terribly.
We need to pause for some configuration. The
file we want is config/init.rb. Not a lot of options, but they're well
commented. All I'm going to do is uncomment two lines.
use_orm :datamapper and use_test :datamapper. This
just tells Merb that I'm using DataMapper and RSpec. I assume it uses this
knowledge to load appropriate libraries or something. I don't really know.
The other bit-o-configuration we need is database.yml. I like to stick with sqlite for development unless I'm intending to use features specific to a given database.
:development: &defaults
:adapter: sqlite3
:database: db/dev.db
:test:
<<: *defaults
:database: db/test.db
:production:
<<: *defaults
:database: db/pro.db
And, before you start scaffolding, create a db directory if there's not one already.
Scaffolding
Yeah, I know, scaffolding sucks, but it's a quick way to get some working code, because this ain't the interesting bit. First, though, what models/resources do I need?
- Article, for the actual articles. This one will become interesting later.
- Comment, for people to leave comments.
- Site, to specify each different site. But I'm going to leave out the multi-site requirement for now.
- User? Nope, I'm not going to bother. Since I know that my article editing will ultimately happen on some text editor and be committed to a Subversion repository, I don't have a need for User accounts. I could add it later, say, if I wanted commenters to have accounts or something. Oh, as an aside, after having completed several Rails apps, the only thing interesting about user accounts are the passwords. For some reason, this gets overcomplicated.
So, just Article and Comment. And nothing too fancy. Note that the underscore in date_time matters. Otherwise, you're liable to get a constant missing error. Another gotcha is that an "id" field is not generated automatically.
merb-gen resource Article
id:integer
title:string
body:text
published_at:date_time
comments_allowed_at:date_time
created_at:date_time
updated_at:date_time
merb-gen resource Comment
id:integer
author:string
email:string
body:text
article_id:integer
parent_id:integer
created_at:date_time
updated_at:date_time
We have several more things to do before we can really get the app running. The first is routing. I understand that Merb's router is quite powerful. But, I'm not intending to venture there for now.
I want the actual code of router.rb to look like this for now (just using REST routing for the two models just created). I'll update this a bit as time goes on.
Merb::Router.prepare do |r|
r.resources :articles
r.resources :comments
r.default_routes
end
Next, specify that id is the primary key for both tables. So, in each model,
change the line property :id, Integer to
property :id, Integer, :serial => true, thus telling DataMapper
that id is an auto-numbering primary key.
Then, migrate the database. Yay, no migration files! This is probably a personal preference, but I really like specifying the tables fields within the model.
rake dm:db:automigrate
The next was a surprise to me. Apparently, link_to is now in the
"merb-assets" gem and must be required explicitly (Thanks to this article for
the solution. Likewise,
"error_messages_for" is in "merb_helpers" (You may need to
gem install merb_helpers). So, add to init.rb
dependencies "merb_helpers", "merb-assets".
To start the app, the command is, well, merb. Add a "-p ####" to specify a port other than 3000.
So, play around, check out the scaffolded code, yadda, yadda.
Clean Up the Scaffolding
The next step is to get the app working like I want it, without messing with the storage in Subversion stuff. One thing to note is that I'm not going to address "look and feel" in this article. (Except sort of at the tail-end). I generally like to start with the models, although I don't really have an "approach". Oh, and I don't plan on going over specs/tests in this article, although I'll be writing some (probably less than some people would prefer).
Anyway, first stop is making the properties in the models work just like I want them.
Validations - I won't validate anything for the Article model because editing will ultimately be done in Subversion, and, well, I generally don't care to validate data that I personally will be inputting. But the Comments will see some changes.
- First, we need 'dm-validations'. There's several places you could require
it (directly, in the model for example), but I'll add it as a dependency
in init.rb. For some reason, I had a version problem, so I specified it
explicitly:
dependency 'dm-validations', '= 0.9.1'. (Later, I removed the version). - Then, add some options to some of the properties. Add
:nullable => falseto #body, #author and #article_id; also, add:length => 100to #author (Because I feel like it); and:format => :email_addressfor #email. By default, DataMapper validates based on this info. So, a:nullable => falseresults in avalidates_present. Of course, you can use explicit validations if desired or needed. I'm not sure how to disable the format validation (for email) when no address has been supplied. So, I'll customize the setter.
def email=(val)
endif val.blank? attribute_set(:email, nil) else attribute_set(:email, val) end
Lazy Loading - DM lazy loads Text fields by default. I don't anticipate
retrieving Articles or Comments without using their #body fields, so, add
:lazy => false option to the #body properties.
Relationships - Comments belong to a) an Article and b) possibly a parent Comment. Associations look a bit different if you're accustomed to ActiveRecord, but nothing too weird. Here's the updates. Some of these associations have some extra options, such as ordering and scope. Note particularly Article#direct_comments.
class Article
has n, :comments
has n, :direct_comments,
:class_name => 'Comment',
:order => [:created_at.asc],
:parent_id => nil
end
class Comment
belongs_to :article
belongs_to :parent,
:class_name => 'Comment',
:child_key => [:parent_id]
has n, :replies,
:class_name => 'Comment',
:child_key => [:parent_id],
:order => [:created_at.asc]
end
I want to be able to call @article.comments.count from my vies, so I need to
add a dependency 'dm-aggregates' in init.rb
Auto Times - I like the auto-updating created_at and updated_at in
AR. To get this in DataMapper, we just need to require "dm_timestamps".
dependency 'dm-timestamps' in init.rb is one way to do this.
Timestamp Booleans - One of my favorite little tricks are timestamp columns that can operate as booleans. I have two in Article #published_at and comments_allowed_at. I'll want the following methods: #published? and #published=(Boolean) (and similar for #comments_allowed_at. Since I might add similar columns later, I'll do some meta-programming here.
class Article
%w{published comments_allowed}.each do | col |
define_method("#{col}=") do |value|
value = false if (value == '0' || value == 0) # for checkboxes
# update only if the boolean value changed.
if (!value == __send__("#{col}?"))
attribute_set("#{col}_at", value ? Time.now : nil)
end
end
define_method("#{col}?") do
__send__("#{col}_at") ? true : false
end
end
end
#attribute_set is preferred to @attribute_name=(value) for "tracking dirtiness".
Auto Migrate again - rake dm:db:automigrate. This will take
care of updating the database with those :nullable => false kind of property
options. I think this is destructive. rake dm:db:autoupgrade,
according to rake -T, is nondestructive. But I don't have any useful data
yet anyway.
Finally, in this "clean up the scaffolding" section, I want to look at the VC side of MVC. There's a few things needed to match up the controllers with the associations specified in the model. I'll also work on the views, although I won't document any of that here. Merb, by the way, supports ERB and HAML. I assume it supports other templating engines; looking at the merb-haml gem, anyway, this doesn't look difficult. I'm going to use HAML for now, because, hey, why not add on something else new. But, the controller/routing changes. (Oh, and I'll ignore the edit and new views for articles; they will after all disappear shortly).
HAML - Add a 'merb-haml' dependency in init.rb
Router - Basically, I just want to use REST routes (for now), with comment routes nested in article routes. Also, add the default route.
Merb::Router.prepare do |r|
r.resources :articles do | article |
article.resources :comments
end
r.match('/').to(:controller => 'articles', :action =>'index')
end
Contents controller - I want to update the Contents controller to scope requests by the article. The key here is a before filter. In this, I'll also assign a parent Comment, if appropriate.
before :assign_article_and_parent
protected
def assign_article_and_parent
@article = Article.get(params[:article_id])
raise NotFound unless @article.nil?
@parent = Comment.get(params[:parent_id]) unless params[:parent_id].blank?
end
There's also some updates such as:
@comment = @article.comments.first(:id => params[:id])
and
@comment = Comment.new
@article.comments << @comment
@parent.replies << @comment if @parent
URLs also need to reflect the nested routing of comments. For example, the redirect in #create becomes:
redirect url(:article_comment,
:article_id => @article.id,
:comment_id => @comment.id)
I also remove the edit, update and destroy actions. The only mechanism I will provide for these for now is the console. This is just to avoid needing an administrative area (even then, though, I'd probably just provide the destroy option).
Articles controller - Finally, I want to limit Articles to those already published. Again, a before filter would work, but I'm just going to create an Article.published method, referenced in index. I could restrict the show action also to only those published, but I'll leave it for previewing, at least for now.
class Article
class << self
def published(options = {})
Article.all(options.merge(
:conditions => ["datetime(published_at) <= datetime('now')"],
:order => [:published_at.desc]))
end
end
end
