Posted by Jim Morris
Wed, 08 Aug 2007 06:53:09 GMT
In my latest web project I potentially have a lot of boolean
preferences, which I use for enabling or disabling various email
notifications to users.
Rather than having to add a migration everytime I want to add a new
preference, I thought I would use the composed_of feature in my model
and compose the boolean preferences from a bitvector. That way I can
simply modify my model to add new preferences rather than add new
columns to the database.
I also wanted this to be easy to add new boolean preferences, so I use
some Macros (I guess you could also call it Meta-Programming) to do
all the repetitive code.
The result is a little class in my Person model called Preferences,
one integer field in my persons database called preferences, and a
composed_of :preferences in the Person model, and of course the
following class in the person.rb model.
class Person < ActiveRecord::Base
class Preferences
@@bits= {
:comment_notifications => 1,
:friendship_notifications => 2,
:event_notifications => 4,
:misc_notifications => 8 }
@@bits.each_key do |a|
attr_reader a
end
def initialize(prefs)
if prefs.nil?
@@bits.each do |a, v|
instance_variable_set("@#{a}", false)
end
@comment_notifications= true
@event_notifications= true
elsif prefs.is_a?(Hash)
@@bits.each do |a, v|
instance_variable_set("@#{a}", false)
end
prefs.each do |k,v|
raise(ArgumentError, "Unknown preference #{k}") unless @@bits.has_key?(k.to_sym)
instance_variable_set("@#{k}", true) if v == '1'
end
else
@@bits.each do |a, v|
instance_variable_set("@#{a}", (prefs & v) != 0 ? true : false)
end
end
end
def preferences
bv= 0
@@bits.each do |a, v|
bv |= instance_variable_get("@#{a}") ? v : 0
end
return bv
end
@@bits.each_key do |a|
alias_method((a.to_s + '?').to_sym, a)
end
end
composed_of :preferences
end
All I need to do to add new preferences is add it to the @@bits
class variable, which is a Hash of the preference name as a symbol and
the bit it sets in the integer (actually the value of the bit, bit0 is
1, bit1 is 2 etc). The rest of the code is derived from the class
variable.
To make things easier I also add a predicate for each preference, so I
can access @person.preferences.comment_notifications? to see if any
comment notifications are required for instance.
One other thing I do in the initialize method is set up defaults for
the preferences. This is only really needed if it is being added as an
after thought, and the column preferences is NULL in the database.
Because composed_of classes are immutable you must always create a
whole new one to update them, so I also allow initialize to be called
with a Hash, which can come straight from the controller. The last
case of initialize is being passed the integer from the database,
expanding it into the various boolean instance variables. The
preferences method does the reverse and converts the boolean
instance variables into the bit vector. Calls to these are all taken
care of by ActiveRecord.
An example of it being called from the controller is...
@person.preferences= Person::Preferences.new(params[:preferences])
presuming you have a bunch of check boxes in your view which are
passed in as part of the preferences hash.
Because I added this later I had one migration to initially add the
new column...
add_column :people, :preferences, :integer
Posted in Rails | Tags bitvector, composed_of, preferences, rails | 2 comments | no trackbacks
Posted by Jim Morris
Wed, 08 Aug 2007 06:53:09 GMT
In my latest web project I potentially have a lot of boolean
preferences, which I use for enabling or disabling various email
notifications to users.
Rather than having to add a migration everytime I want to add a new
preference, I thought I would use the composed_of feature in my model
and compose the boolean preferences from a bitvector. That way I can
simply modify my model to add new preferences rather than add new
columns to the database.
I also wanted this to be easy to add new boolean preferences, so I use
some Macros (I guess you could also call it Meta-Programming) to do
all the repetitive code.
The result is a little class in my Person model called Preferences,
one integer field in my persons database called preferences, and a
composed_of :preferences in the Person model, and of course the
following class in the person.rb model.
class Person < ActiveRecord::Base
class Preferences
@@bits= {
:comment_notifications => 1,
:friendship_notifications => 2,
:event_notifications => 4,
:misc_notifications => 8 }
@@bits.each_key do |a|
attr_reader a
end
def initialize(prefs)
if prefs.nil?
@@bits.each do |a, v|
instance_variable_set("@#{a}", false)
end
@comment_notifications= true
@event_notifications= true
elsif prefs.is_a?(Hash)
@@bits.each do |a, v|
instance_variable_set("@#{a}", false)
end
prefs.each do |k,v|
raise(ArgumentError, "Unknown preference #{k}") unless @@bits.has_key?(k.to_sym)
instance_variable_set("@#{k}", true) if v == '1'
end
else
@@bits.each do |a, v|
instance_variable_set("@#{a}", (prefs & v) != 0 ? true : false)
end
end
end
def preferences
bv= 0
@@bits.each do |a, v|
bv |= instance_variable_get("@#{a}") ? v : 0
end
return bv
end
@@bits.each_key do |a|
alias_method((a.to_s + '?').to_sym, a)
end
end
composed_of :preferences
end
All I need to do to add new preferences is add it to the @@bits
class variable, which is a Hash of the preference name as a symbol and
the bit it sets in the integer (actually the value of the bit, bit0 is
1, bit1 is 2 etc). The rest of the code is derived from the class
variable.
To make things easier I also add a predicate for each preference, so I
can access @person.preferences.comment_notifications? to see if any
comment notifications are required for instance.
One other thing I do in the initialize method is set up defaults for
the preferences. This is only really needed if it is being added as an
after thought, and the column preferences is NULL in the database.
Because composed_of classes are immutable you must always create a
whole new one to update them, so I also allow initialize to be called
with a Hash, which can come straight from the controller. The last
case of initialize is being passed the integer from the database,
expanding it into the various boolean instance variables. The
preferences method does the reverse and converts the boolean
instance variables into the bit vector. Calls to these are all taken
care of by ActiveRecord.
An example of it being called from the controller is...
@person.preferences= Person::Preferences.new(params[:preferences])
presuming you have a bunch of check boxes in your view which are
passed in as part of the preferences hash.
Because I added this later I had one migration to initially add the
new column...
add_column :people, :preferences, :integer
Posted in Rails | Tags bitvector, composed_of, preferences, rails | 2 comments | no trackbacks
Posted by Jim Morris
Mon, 30 Jul 2007 22:04:50 GMT
A question I see asked a lot is how do I paginate acts_as_taggable
(on steroids)?
I haven't seen any answers I liked, so I created my own, which I'm
sure a few people won't like either ;) But it works for me (tm).
I use will_paginate, but this does
not work with custom finds that plugins define themselves, as is the
case with
acts_as_taggable.
If you do use a custom find_by_sql you have to hit the database twice, once to find the total number
of items and then the paginated find.
I have a situation where I generate a
tag cloud
with every page, and part of that tag cloud has already calculated the
number of tags for each classification I use.
I combined this with the paginator gem
to get myself pages without too many hits to the database.
The first thing I do is pass the total count I get from the tag cloud
to the action that renders the index for all those items matching the
cloud... If you look at the article cited above I make this
modification, this is dumbed down a bit for the sake of simplicity...
tags= Post.tag_counts(:order => 'tags.name')
tags.each do |t|
link_to(h(t.name), tagged_post_path(:tag => t.name, :cnt => t.count))
end
This passes the count I have already calculated to the action that
will list the paginated results.
In my controller I do this to get the paginated results using
will_paginate and the Pagination gem...
def tagged
...
size= params[:cnt]
if size
per_page= 10
pager = ::Paginator.new(size, per_page) do |offset, per_page|
Post.find_tagged_with(params[:tag], :limit => per_page, :offset => offset)
end
page= params[:page] || 1
@posts= returning WillPaginate::Collection.new(page, per_page, size) do |p|
p.replace pager.page(page).items
end
else
@posts= Post.find_tagged_with(params[:tag])
end
render :action => 'index'
end
This fits in nicely with the tag cloud I need to calculate, and it
uses will_paginate just like the regular index action does.
UPDATE
I refactored this to be more generally useful, I added the following as a
protected method in application.rb...
def tag_paginator(klass, tag, count=nil, per_page=10, page=1, order='updated_at DESC')
count ||= klass.count_tags(tag)
pager = ::Paginator.new(count, per_page) do |offset, per_page|
klass.find_tagged_with(tag, :order => order, :limit => per_page, :offset => offset)
end
page ||= 1
returning WillPaginate::Collection.new(page, per_page, count) do |p|
p.replace pager.page(page).items
end
end
I call it from one of my other actions like this...
@faqs= tag_paginator(Post, 'FAQ', nil, per_page, params[:page], 'updated_at DESC')
Passing in nil as the third parameter causes the tag_paginator
method to call Post.count_tags which is not part of the
acts_as_taggable methods, I added it to the SingletonMethods module
myself...
module ActiveRecord
module Acts
module Taggable
module SingletonMethods
...
def count_tags(tag)
count_by_sql("select count(*) FROM tags, taggings WHERE " + sanitize_sql(['name = ? AND tags.id = taggings.tag_id AND taggable_type = ?', tag, name]))
end
....
If you don't want to hack acts_as_taggable then simply leave that
part out and call the count_by_sql yourself.
My refactored tagged action from above now looks like this...
def tagged
...
tag= params[:tag]
per_page= 10
size= params[:cnt]
@posts= tag_paginator(Post, tag, size, 10, params[:page])
Posted in Rails | Tags acts_as_taggable, will_paginate | 10 comments | no trackbacks
Posted by Jim Morris
Mon, 30 Jul 2007 22:04:50 GMT
A question I see asked a lot is how do I paginate acts_as_taggable
(on steroids)?
I haven't seen any answers I liked, so I created my own, which I'm
sure a few people won't like either ;) But it works for me (tm).
I use will_paginate, but this does
not work with custom finds that plugins define themselves, as is the
case with
acts_as_taggable.
If you do use a custom find_by_sql you have to hit the database twice, once to find the total number
of items and then the paginated find.
I have a situation where I generate a
tag cloud
with every page, and part of that tag cloud has already calculated the
number of tags for each classification I use.
I combined this with the paginator gem
to get myself pages without too many hits to the database.
The first thing I do is pass the total count I get from the tag cloud
to the action that renders the index for all those items matching the
cloud... If you look at the article cited above I make this
modification, this is dumbed down a bit for the sake of simplicity...
tags= Post.tag_counts(:order => 'tags.name')
tags.each do |t|
link_to(h(t.name), tagged_post_path(:tag => t.name, :cnt => t.count))
end
This passes the count I have already calculated to the action that
will list the paginated results.
In my controller I do this to get the paginated results using
will_paginate and the Pagination gem...
def tagged
...
size= params[:cnt]
if size
per_page= 10
pager = ::Paginator.new(size, per_page) do |offset, per_page|
Post.find_tagged_with(params[:tag], :limit => per_page, :offset => offset)
end
page= params[:page] || 1
@posts= returning WillPaginate::Collection.new(page, per_page, size) do |p|
p.replace pager.page(page).items
end
else
@posts= Post.find_tagged_with(params[:tag])
end
render :action => 'index'
end
This fits in nicely with the tag cloud I need to calculate, and it
uses will_paginate just like the regular index action does.
UPDATE
I refactored this to be more generally useful, I added the following as a
protected method in application.rb...
def tag_paginator(klass, tag, count=nil, per_page=10, page=1, order='updated_at DESC')
count ||= klass.count_tags(tag)
pager = ::Paginator.new(count, per_page) do |offset, per_page|
klass.find_tagged_with(tag, :order => order, :limit => per_page, :offset => offset)
end
page ||= 1
returning WillPaginate::Collection.new(page, per_page, count) do |p|
p.replace pager.page(page).items
end
end
I call it from one of my other actions like this...
@faqs= tag_paginator(Post, 'FAQ', nil, per_page, params[:page], 'updated_at DESC')
Passing in nil as the third parameter causes the tag_paginator
method to call Post.count_tags which is not part of the
acts_as_taggable methods, I added it to the SingletonMethods module
myself...
module ActiveRecord
module Acts
module Taggable
module SingletonMethods
...
def count_tags(tag)
count_by_sql("select count(*) FROM tags, taggings WHERE " + sanitize_sql(['name = ? AND tags.id = taggings.tag_id AND taggable_type = ?', tag, name]))
end
....
If you don't want to hack acts_as_taggable then simply leave that
part out and call the count_by_sql yourself.
My refactored tagged action from above now looks like this...
def tagged
...
tag= params[:tag]
per_page= 10
size= params[:cnt]
@posts= tag_paginator(Post, tag, size, 10, params[:page])
Posted in Rails | Tags acts_as_taggable, will_paginate | 10 comments | no trackbacks