Skip to content

Commit

Permalink
Add composition plugin, simlar to ActiveRecord's composed_of
Browse files Browse the repository at this point in the history
The composition plugin allows you to easily define getter and setter
instance methods for a class where the backing data is composed of
other getters and decomposed to other setters.

A simple example of this is when you have a database table with
separate columns for year, month, and day, but where you want to deal
with Date objects in your ruby code.  This can be handled with:

  Model.composition :date, :mapping=>[:year, :month, :day]

The :mapping option is optional, but you can define custom
composition and decomposition procs via the :composer and :decomposer
options.

Note that when using the composition object, you should not modify the
underlying columns if you are also instantiating the composition, as
otherwise the composition object values will override any underlying
columns when the object is saved
  • Loading branch information
jeremyevans committed Mar 8, 2010
1 parent 2e5525e commit 86db4dc
Show file tree
Hide file tree
Showing 5 changed files with 388 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
@@ -1,5 +1,7 @@
=== HEAD

* Add composition plugin, simlar to ActiveRecord's composed_of (jeremyevans)

* Combine multiple complex expressions for simpler SQL and object tree (jeremyevans)

* Add Dataset#first_source_table, for the unaliased version of the table for the first source (jeremyevans)
Expand Down
138 changes: 138 additions & 0 deletions lib/sequel/plugins/composition.rb
@@ -0,0 +1,138 @@
module Sequel
module Plugins
# The composition plugin allows you to easily define getter and
# setter instance methods for a class where the backing data
# is composed of other getters and decomposed to other setters.
#
# A simple example of this is when you have a database table with
# separate columns for year, month, and day, but where you want
# to deal with Date objects in your ruby code. This can be handled
# with:
#
# Model.composition :date, :mapping=>[:year, :month, :day]
#
# The :mapping option is optional, but you can define custom
# composition and decomposition procs via the :composer and
# :decomposer options.
#
# Note that when using the composition object, you should not
# modify the underlying columns if you are also instantiating
# the composition, as otherwise the composition object values
# will override any underlying columns when the object is saved.
module Composition
# Define the necessary class instance variables.
def self.apply(model)
model.instance_eval{@compositions = {}}
end

module ClassMethods
# A hash with composition name keys and composition reflection
# hash values.
attr_reader :compositions

# A module included in the class holding the composition
# getter and setter methods.
attr_reader :composition_module

# Define a composition for this model, with name being the name of the composition.
# You must provide either a :mapping option or both the :composer and :decomposer options.
#
# Options:
# * :class - if using the :mapping option, the class to use, as a Class, String or Symbol.
# * :composer - A proc that is instance evaled when the composition getter method is called
# to create the composition.
# * :decomposer - A proc that is instance evaled before saving the model object,
# if the composition object exists, which sets the columns in the model object
# based on the value of the composition object.
# * :mapping - An array where each element is either a symbol or an array of two symbols.
# A symbol is treated like an array of two symbols where both symbols are the same.
# The first symbol represents the getter method in the model, and the second symbol
# represents the getter method in the composition object. Example:
# # Uses columns year, month, and day in the current model
# # Uses year, month, and day methods in the composition object
# :mapping=>[:year, :month, :day]
# # Uses columns year, month, and day in the current model
# # Uses y, m, and d methods in the composition object where
# # for example y in the composition object represents year
# # in the model object.
# :mapping=>[[:year, :y], [:month, :m], [:day, :d]]
def composition(name, opts={})
opts = opts.dup
compositions[name] = opts
if mapping = opts[:mapping]
keys = mapping.map{|k| k.is_a?(Array) ? k.first : k}
if !opts[:composer]
late_binding_class_option(opts, name)
klass = opts[:class]
class_proc = proc{klass || constantize(opts[:class_name])}
opts[:composer] = proc do
if values = keys.map{|k| send(k)} and values.any?{|v| !v.nil?}
class_proc.call.new(*values)
else
nil
end
end
end
if !opts[:decomposer]
setter_meths = keys.map{|k| :"#{k}="}
cov_methods = mapping.map{|k| k.is_a?(Array) ? k.last : k}
setters = setter_meths.zip(cov_methods)
opts[:decomposer] = proc do
if (o = compositions[name]).nil?
setter_meths.each{|sm| send(sm, nil)}
else
setters.each{|sm, cm| send(sm, o.send(cm))}
end
end
end
end
raise(Error, "Must provide :composer and :decomposer options, or :mapping option") unless opts[:composer] && opts[:decomposer]
define_composition_accessor(name, opts)
end

# Copy the necessary class instance variables to the subclass.
def inherited(subclass)
super
c = compositions.dup
subclass.instance_eval{@compositions = c}
end

# Define getter and setter methods for the composition object.
def define_composition_accessor(name, opts={})
include(@composition_module ||= Module.new) unless composition_module
composer = opts[:composer]
composition_module.class_eval do
define_method(name) do
compositions.include?(name) ? compositions[name] : (compositions[name] = instance_eval(&composer))
end
define_method("#{name}=") do |v|
modified!
compositions[name] = v
end
end
end
end

module InstanceMethods
# Clear the cached compositions when refreshing.
def _refresh(ds)
v = super
compositions.clear
v
end

# For each composition, set the columns in the model class based
# on the composition object.
def before_save
@compositions.keys.each{|n| instance_eval(&model.compositions[n][:decomposer])} if @compositions
super
end

# Cache of composition objects for this class.
def compositions
@compositions ||= {}
end
end
end
end
end
194 changes: 194 additions & 0 deletions spec/extensions/composition_spec.rb
@@ -0,0 +1,194 @@
require File.join(File.dirname(__FILE__), "spec_helper")

require 'yaml'
require 'json'

describe "Serialization plugin" do
before do
@c = Class.new(Sequel::Model(:items))
@c.plugin :composition
@c.columns :id, :year, :month, :day
@o = @c.load(:id=>1, :year=>1, :month=>2, :day=>3)
MODEL_DB.reset
end

it ".composition should add compositions" do
@o.should_not respond_to(:date)
@c.composition :date, :mapping=>[:year, :month, :day]
@o.date.should == Date.new(1, 2, 3)
end

it "loading the plugin twice should not remove existing compositions" do
@c.composition :date, :mapping=>[:year, :month, :day]
@c.plugin :composition
@c.compositions.keys.should == [:date]
end

it ".composition should raise an error if :composer and :decomposer options are not present and :mapping option is not provided" do
proc{@c.composition :date}.should raise_error(Sequel::Error)
proc{@c.composition :date, :composer=>proc{}, :decomposer=>proc{}}.should_not raise_error
proc{@c.composition :date, :mapping=>[]}.should_not raise_error
end

it ".compositions should return the reflection hash of compositions" do
@c.compositions.should == {}
@c.composition :date, :mapping=>[:year, :month, :day]
@c.compositions.keys.should == [:date]
r = @c.compositions.values.first
r[:mapping].should == [:year, :month, :day]
r[:composer].should be_a_kind_of(Proc)
r[:decomposer].should be_a_kind_of(Proc)
end

it "#compositions should be a hash of cached values of compositions" do
@o.compositions.should == {}
@c.composition :date, :mapping=>[:year, :month, :day]
@o.date
@o.compositions.should == {:date=>Date.new(1, 2, 3)}
end

it "should work with custom :composer and :decomposer options" do
@c.composition :date, :composer=>proc{Date.new(year+1, month+2, day+3)}, :decomposer=>proc{[:year, :month, :day].each{|s| self.send("#{s}=", date.send(s) * 2)}}
@o.date.should == Date.new(2, 4, 6)
@o.save
MODEL_DB.sqls.last.should include("year = 4")
MODEL_DB.sqls.last.should include("month = 8")
MODEL_DB.sqls.last.should include("day = 12")
end

it "should allow call super in composition getter and setter method definition in class" do
@c.composition :date, :mapping=>[:year, :month, :day]
@c.class_eval do
def date
super + 1
end
def date=(v)
super(v - 3)
end
end
@o.date.should == Date.new(1, 2, 4)
@o.compositions[:date].should == Date.new(1, 2, 3)
@o.date = Date.new(1, 3, 5)
@o.compositions[:date].should == Date.new(1, 3, 2)
@o.date.should == Date.new(1, 3, 3)
end

it "should mark the object as modified whenever the composition is set" do
@c.composition :date, :mapping=>[:year, :month, :day]
@o.modified?.should == false
@o.date = Date.new(3, 4, 5)
@o.modified?.should == true
end

it "should only decompose existing compositions" do
called = false
@c.composition :date, :composer=>proc{}, :decomposer=>proc{called = true}
called.should == false
@o.save
called.should == false
@o.date = Date.new(1,2,3)
called.should == false
@o.save_changes
called.should == true
end

it "should clear compositions cache when reloading" do
@c.composition :date, :composer=>proc{}, :decomposer=>proc{called = true}
@o.date = Date.new(3, 4, 5)
@o.reload
@o.compositions.should == {}
end

it "should instantiate compositions lazily" do
@c.composition :date, :mapping=>[:year, :month, :day]
@o.compositions.should == {}
@o.date
@o.compositions.should == {:date=>Date.new(1,2,3)}
end

it "should cache value of composition" do
times = 0
@c.composition :date, :composer=>proc{times+=1}, :decomposer=>proc{called = true}
times.should == 0
@o.date
times.should == 1
@o.date
times.should == 1
end

it ":class option should take an string, symbol, or class" do
@c.composition :date1, :class=>'Date', :mapping=>[:year, :month, :day]
@c.composition :date2, :class=>:Date, :mapping=>[:year, :month, :day]
@c.composition :date3, :class=>Date, :mapping=>[:year, :month, :day]
@o.date1.should == Date.new(1, 2, 3)
@o.date2.should == Date.new(1, 2, 3)
@o.date3.should == Date.new(1, 2, 3)
end

it ":mapping option should work with a single array of symbols" do
c = Class.new do
def initialize(y, m)
@y, @m = y, m
end
def year
@y * 2
end
def month
@m * 3
end
end
@c.composition :date, :class=>c, :mapping=>[:year, :month]
@o.date.year.should == 2
@o.date.month.should == 6
@o.date = c.new(3, 4)
@o.save
MODEL_DB.sqls.last.should include("year = 6")
MODEL_DB.sqls.last.should include("month = 12")
end

it ":mapping option should work with an array of two pairs of symbols" do
c = Class.new do
def initialize(y, m)
@y, @m = y, m
end
def y
@y * 2
end
def m
@m * 3
end
end
@c.composition :date, :class=>c, :mapping=>[[:year, :y], [:month, :m]]
@o.date.y.should == 2
@o.date.m.should == 6
@o.date = c.new(3, 4)
@o.save
MODEL_DB.sqls.last.should include("year = 6")
MODEL_DB.sqls.last.should include("month = 12")
end

it ":mapping option :composer should return nil if all values are nil" do
@c.composition :date, :mapping=>[:year, :month, :day]
@c.new.date.should == nil
end

it ":mapping option :decomposer should set all related fields to nil if nil" do
@c.composition :date, :mapping=>[:year, :month, :day]
@o.date = nil
@o.save
MODEL_DB.sqls.last.should include("year = NULL")
MODEL_DB.sqls.last.should include("month = NULL")
MODEL_DB.sqls.last.should include("day = NULL")
end

it "should work correctly with subclasses" do
@c.composition :date, :mapping=>[:year, :month, :day]
c = Class.new(@c)
o = c.load(:id=>1, :year=>1, :month=>2, :day=>3)
o.date.should == Date.new(1, 2, 3)
o.save
MODEL_DB.sqls.last.should include("year = 1")
MODEL_DB.sqls.last.should include("month = 2")
MODEL_DB.sqls.last.should include("day = 3")
end
end
53 changes: 53 additions & 0 deletions spec/integration/plugin_test.rb
Expand Up @@ -482,3 +482,56 @@ class ::Person < Sequel::Model(@db)
proc{p1.update(:name=>'Bob')}.should_not raise_error
end
end

describe "Composition plugin" do
before do
@db = INTEGRATION_DB
@db.create_table!(:events) do
primary_key :id
Integer :year
Integer :month
Integer :day
end
class ::Event < Sequel::Model(@db)
plugin :composition
composition :date, :composer=>proc{Date.new(year, month, day) if year && month && day}, :decomposer=>(proc do
if date
self.year = date.year
self.month = date.month
self.day = date.day
else
self.year, self.month, self.day = nil
end
end)
composition :date, :mapping=>[:year, :month, :day]
end
@e1 = Event.create(:year=>2010, :month=>2, :day=>15)
@e2 = Event.create({})
end
after do
@db.drop_table(:events)
Object.send(:remove_const, :Event)
end

specify "should return a composed object if the underlying columns have a value" do
@e1.date.should == Date.civil(2010, 2, 15)
@e2.date.should == nil
end

specify "should decompose the object when saving the record" do
@e1.date = Date.civil(2009, 1, 2)
@e1.save
@e1.year.should == 2009
@e1.month.should == 1
@e1.day.should == 2
end

specify "should save all columns when saving changes" do
@e2.date = Date.civil(2009, 10, 2)
@e2.save_changes
@e2.reload
@e2.year.should == 2009
@e2.month.should == 10
@e2.day.should == 2
end
end

0 comments on commit 86db4dc

Please sign in to comment.