From 86db4dc744d0baa710ee766773f6cdf72c0b1b09 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Fri, 5 Mar 2010 15:40:46 -0800 Subject: [PATCH] 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 --- CHANGELOG | 2 + lib/sequel/plugins/composition.rb | 138 ++++++++++++++++++++ spec/extensions/composition_spec.rb | 194 ++++++++++++++++++++++++++++ spec/integration/plugin_test.rb | 53 ++++++++ www/pages/plugins | 1 + 5 files changed, 388 insertions(+) create mode 100644 lib/sequel/plugins/composition.rb create mode 100644 spec/extensions/composition_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 781038965b..e5bf4eafda 100644 --- a/CHANGELOG +++ b/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) diff --git a/lib/sequel/plugins/composition.rb b/lib/sequel/plugins/composition.rb new file mode 100644 index 0000000000..245dd6371d --- /dev/null +++ b/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 diff --git a/spec/extensions/composition_spec.rb b/spec/extensions/composition_spec.rb new file mode 100644 index 0000000000..b2f8b875a0 --- /dev/null +++ b/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 diff --git a/spec/integration/plugin_test.rb b/spec/integration/plugin_test.rb index 7621200a28..96043e5919 100644 --- a/spec/integration/plugin_test.rb +++ b/spec/integration/plugin_test.rb @@ -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 diff --git a/www/pages/plugins b/www/pages/plugins index 2ad35ff411..4f0f6d0125 100644 --- a/www/pages/plugins +++ b/www/pages/plugins @@ -11,6 +11,7 @@
  • boolean_readers: Adds attribute? methods for all boolean columns.
  • caching: Supports caching primary key lookups of model objects to any object that supports the Ruby-Memcache API.
  • class_table_inheritance: Supports inheritance in the database by using a single database table for each class in a class hierarchy.
  • +
  • composition: Supports defining getters/setters for objects with data backed by the model's columns.
  • force_encoding: Forces the all model column string values to a given encoding.
  • hook_class_methods: Adds backwards compatiblity for the legacy class-level hook methods (e.g. before_save :do_something).
  • identity_map: Allows you to create temporary identity maps which ensure a 1-1 correspondence of model objects to database rows.