Skip to content

Commit

Permalink
Add modification_detection plugin, for automatic detection of in-plac…
Browse files Browse the repository at this point in the history
…e column value modifications

This is a simple if fairly slow way to reliably detect in-place
column value modifications.  Well, it's reliable if the objects
themselves implement #hash correctly, but if they don't then
those objects should be fixed.

ActiveRecord is going to be doing something similar in the future
by default.  I don't think it's a good idea to force a slowdown
on all users just because some users may modify values in
place, so it will remain a plugin in Sequel.  Users who care
about performance should continue using the existing
modified!(:column) method to manually mark column values as
changed when changing them in-place.
  • Loading branch information
jeremyevans committed Jul 9, 2014
1 parent b137865 commit a9c4e06
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
90 changes: 90 additions & 0 deletions lib/sequel/plugins/modification_detection.rb
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions spec/extensions/modification_detection_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a9c4e06

Please sign in to comment.