-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add composition plugin, simlar to ActiveRecord's composed_of
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
1 parent
2e5525e
commit 86db4dc
Showing
5 changed files
with
388 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.