diff --git a/CHANGELOG b/CHANGELOG index 47b1a66d35..fd3d81f1b2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ === HEAD +* Add modification_detection plugin, for automatic detection of in-place column value modifications (jeremyevans) + * Speed up using plain strings, numbers, true, false, and nil in json columns if underlying json library supports them (jeremyevans) (#834) === 4.12.0 (2014-07-01) diff --git a/lib/sequel/plugins/modification_detection.rb b/lib/sequel/plugins/modification_detection.rb new file mode 100644 index 0000000000..92a36905b3 --- /dev/null +++ b/lib/sequel/plugins/modification_detection.rb @@ -0,0 +1,90 @@ +module Sequel + module Plugins + # This plugin automatically detects in-place modifications to + # columns as well as direct modifications of the values hash. + # + # class User < Sequel::Model + # plugin :modification_detection + # end + # user = User[1] + # user.a # => 'a' + # user.a << 'b' + # user.save_changes + # # UPDATE users SET a = 'ab' WHERE (id = 1) + # + # Note that for this plugin to work correctly, the column values must + # correctly implement the #hash method, returning the same value if + # the object is equal, and a different value if the object is not equal. + # + # Note that this plugin causes a performance hit for all retrieved + # objects, so it shouldn't be used in cases where performance is a + # primary concern. + # + # Usage: + # + # # Make all model subclass automatically detect column modifications + # Sequel::Model.plugin :modification_detection + # + # # Make the Album class automatically detect column modifications + # Album.plugin :modification_detection + module ModificationDetection + module ClassMethods + # Calculate the hashes for all of the column values, so that they + # can be compared later to determine if the column value has changed. + def call(_) + v = super + v.calculate_values_hashes + v + end + end + + module InstanceMethods + # Recalculate the column value hashes after updating. + def after_update + super + recalculate_values_hashes + end + + # Calculate the column hash values if they haven't been already calculated. + def calculate_values_hashes + @values_hashes || recalculate_values_hashes + end + + # Detect which columns have been modified by comparing the cached hash + # value to the hash of the current value. + def changed_columns + cc = super + changed = [] + v = @values + if vh = @values_hashes + (vh.keys - cc).each{|c| changed << c unless v.has_key?(c) && vh[c] == v[c].hash} + end + cc + changed + end + + private + + # Recalculate the column value hashes after manually refreshing. + def _refresh(dataset) + super + recalculate_values_hashes + end + + # Recalculate the column value hashes after refreshing after saving a new object. + def _save_refresh + super + recalculate_values_hashes + end + + # Recalculate the column value hashes, caching them for later use. + def recalculate_values_hashes + vh = {} + @values.each do |k,v| + vh[k] = v.hash + end + @values_hashes = vh.freeze + end + end + end + end +end diff --git a/spec/extensions/modification_detection_spec.rb b/spec/extensions/modification_detection_spec.rb new file mode 100644 index 0000000000..a2107d8c04 --- /dev/null +++ b/spec/extensions/modification_detection_spec.rb @@ -0,0 +1,80 @@ +require File.join(File.dirname(File.expand_path(__FILE__)), "spec_helper") +require 'yaml' + +describe "serialization_modification_detection plugin" do + before do + @ds = Sequel.mock(:fetch=>{:id=>1, :a=>'a', :b=>1, :c=>['a'], :d=>{'b'=>'c'}}, :numrows=>1, :autoid=>1)[:items] + @c = Class.new(Sequel::Model(@ds)) + @c.plugin :modification_detection + @c.columns :a, :b, :c, :d + @o = @c.first + @ds.db.sqls + end + + it "should only detect columns that have been changed" do + @o.changed_columns.should == [] + @o.a << 'b' + @o.changed_columns.should == [:a] + @o.a.replace('a') + @o.changed_columns.should == [] + + @o.values[:b] = 2 + @o.changed_columns.should == [:b] + @o.values[:b] = 1 + @o.changed_columns.should == [] + + @o.c[0] << 'b' + @o.d['b'] << 'b' + @o.changed_columns.sort_by{|c| c.to_s}.should == [:c, :d] + @o.c[0] = 'a' + @o.changed_columns.should == [:d] + @o.d['b'] = 'c' + @o.changed_columns.should == [] + end + + it "should not list a column twice" do + @o.a = 'b' + @o.a << 'a' + @o.changed_columns.should == [:a] + end + + it "should report correct changed_columns after updating" do + @o.a << 'a' + @o.save_changes + @o.changed_columns.should == [] + + @o.values[:b] = 2 + @o.save_changes + @o.changed_columns.should == [] + + @o.c[0] << 'b' + @o.save_changes + @o.changed_columns.should == [] + + @o.d['b'] << 'a' + @o.save_changes + @o.changed_columns.should == [] + + @ds.db.sqls.should == ["UPDATE items SET a = 'aa' WHERE (id = 1)", + "UPDATE items SET b = 2 WHERE (id = 1)", + "UPDATE items SET c = ('ab') WHERE (id = 1)", + "UPDATE items SET d = ('b' = 'ca') WHERE (id = 1)"] + end + + it "should report correct changed_columns after creating new object" do + o = @c.create + o.changed_columns.should == [] + o.a << 'a' + o.changed_columns.should == [:a] + @ds.db.sqls.should == ["INSERT INTO items DEFAULT VALUES", "SELECT * FROM items WHERE (id = 1) LIMIT 1"] + end + + it "should report correct changed_columns after refreshing existing object" do + @o.a << 'a' + @o.changed_columns.should == [:a] + @o.refresh + @o.changed_columns.should == [] + @o.a << 'a' + @o.changed_columns.should == [:a] + end +end