diff --git a/CHANGELOG b/CHANGELOG index b84c0ae999..39074ce862 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ === HEAD +* Add instance_filters plugin, for adding arbitrary filters when updating/destroying the instance (jeremyevans) + * No longer create the #{plugin}_opts methods for plugins (jeremyevans) * Support :auto_vacuum, :foreign_keys, :synchronous, and :temp_store Database options on SQLite, for thread-safe PRAGMA setting (jeremyevans) diff --git a/lib/sequel/plugins/instance_filters.rb b/lib/sequel/plugins/instance_filters.rb new file mode 100644 index 0000000000..8a5d4b5f38 --- /dev/null +++ b/lib/sequel/plugins/instance_filters.rb @@ -0,0 +1,102 @@ +module Sequel + module Plugins + # This plugin allows you to add filters on a per object basis that + # restrict updating or deleting the object. It's designed for cases + # where you would normally have to drop down to the dataset level + # to get the necessary control, because you only want to delete or + # update the rows in certain cases based on the current status of + # the row in the database. + # + # class Item < Sequel::Model + # plugin :instance_filters + # end + # + # # These are two separate objects that represent the same + # # database row. + # i1 = Item.first(:id=>1, :delete_allowed=>false) + # i2 = Item.first(:id=>1, :delete_allowed=>false) + # + # # Add an instance filter to the object. This filter is in effect + # # until the object is successfully updated or deleted. + # i1.instance_filter(:delete_allowed=>true) + # + # # Attempting to delete the object where the filter doesn't + # # match any rows raises an error. + # i1.delete # raises Sequel::Error + # + # # The other object that represents the same row has no + # # instance filters, and can be updated normally. + # i2.update(:delete_allowed=>true) + # + # # Even though the filter is now still in effect, since the + # # database row has been updated to allow deleting, + # # delete now works. + # i1.delete + module InstanceFilters + # Exception class raised when updating or deleting an object does + # not affect exactly one row. + class Error < Sequel::Error + end + + module InstanceMethods + # Clear the instance filters after successfully destroying the object. + def after_destroy + super + clear_instance_filters + end + + # Clear the instance filters after successfully updating the object. + def after_update + super + clear_instance_filters + end + + # Add an instance filter to the array of instance filters + # Both the arguments given and the block are passed to the + # dataset's filter method. + def instance_filter(*args, &block) + instance_filters << [args, block] + end + + private + + # Lazily initialize the instance filter array. + def instance_filters + @instance_filters ||= [] + end + + # Apply the instance filters to the given dataset + def apply_instance_filters(ds) + instance_filters.inject(ds){|ds, i| ds.filter(*i[0], &i[1])} + end + + # Clear the instance filters. + def clear_instance_filters + instance_filters.clear + end + + # Apply the instance filters to the dataset returned by super. + def _delete_dataset + apply_instance_filters(super) + end + + # Apply the instance filters to the dataset returned by super. + def _update_dataset + apply_instance_filters(super) + end + + # Raise an Error if calling deleting doesn't + # indicate that a single row was deleted. + def _delete + raise(Error, "No matching object for instance filtered dataset (SQL: #{_delete_dataset.delete_sql})") if super != 1 + end + + # Raise an Error if updating doesn't indicate that a single + # row was updated. + def _update(columns) + raise(Error, "No matching object for instance filtered dataset (SQL: #{_update_dataset.update_sql(columns)})") if super != 1 + end + end + end + end +end diff --git a/spec/extensions/instance_filters_spec.rb b/spec/extensions/instance_filters_spec.rb new file mode 100644 index 0000000000..e1c88c20ff --- /dev/null +++ b/spec/extensions/instance_filters_spec.rb @@ -0,0 +1,55 @@ +require File.join(File.dirname(__FILE__), "spec_helper") + +describe "instance_filters plugin" do + before do + @c = Class.new(Sequel::Model(:people)) do + end + @sql = sql = '' + @v = v = [1] + @c.dataset.quote_identifiers = false + @c.dataset.meta_def(:update) do |opts| + sql.replace(update_sql(opts)) + return v.first + end + @c.dataset.meta_def(:delete) do + sql.replace(delete_sql) + return v.first + end + @c.columns :id, :name, :num + @c.plugin :instance_filters + @p = @c.load(:id=>1, :name=>'John', :num=>1) + end + + specify "should raise an error when updating a stale record" do + @p.update(:name=>'Bob') + @sql.should == "UPDATE people SET name = 'Bob' WHERE (id = 1)" + @p.instance_filter(:name=>'Jim') + @v.replace([0]) + proc{@p.update(:name=>'Joe')}.should raise_error(Sequel::Plugins::InstanceFilters::Error) + @sql.should == "UPDATE people SET name = 'Joe' WHERE ((id = 1) AND (name = 'Jim'))" + end + + specify "should raise an error when destroying a stale record" do + @p.destroy + @sql.should == "DELETE FROM people WHERE (id = 1)" + @p.instance_filter(:name=>'Jim') + @v.replace([0]) + proc{@p.destroy}.should raise_error(Sequel::Plugins::InstanceFilters::Error) + @sql.should == "DELETE FROM people WHERE ((id = 1) AND (name = 'Jim'))" + end + + specify "should apply all instance filters" do + @p.instance_filter(:name=>'Jim') + @p.instance_filter{num > 2} + @p.update(:name=>'Bob') + @sql.should == "UPDATE people SET name = 'Bob' WHERE ((id = 1) AND (name = 'Jim') AND (num > 2))" + end + + specify "should drop instance filters after updating" do + @p.instance_filter(:name=>'Joe') + @p.update(:name=>'Joe') + @sql.should == "UPDATE people SET name = 'Joe' WHERE ((id = 1) AND (name = 'Joe'))" + @p.update(:name=>'Bob') + @sql.should == "UPDATE people SET name = 'Bob' WHERE (id = 1)" + end +end diff --git a/spec/integration/plugin_test.rb b/spec/integration/plugin_test.rb index cabde9f990..f0b9bd2b60 100644 --- a/spec/integration/plugin_test.rb +++ b/spec/integration/plugin_test.rb @@ -752,3 +752,63 @@ class ::Node < Sequel::Model(@db) end end end + +describe "Instance Filters plugin" do + before do + @db = INTEGRATION_DB + @db.create_table!(:items) do + primary_key :id + String :name + Integer :cost + Integer :number + end + class ::Item < Sequel::Model(@db) + plugin :instance_filters + end + @i = Item.create(:name=>'J', :number=>1, :cost=>2) + @i.instance_filter(:number=>1) + @i.set(:name=>'K') + end + after do + @db.drop_table(:items) + Object.send(:remove_const, :Item) + end + + specify "should not raise an error if saving only updates one row" do + @i.save + @i.refresh.name.should == 'K' + end + + specify "should raise error if saving doesn't update a row" do + @i.this.update(:number=>2) + proc{@i.save}.should raise_error(Sequel::Error) + end + + specify "should apply all instance filters" do + @i.instance_filter{cost <= 2} + @i.this.update(:number=>2) + proc{@i.save}.should raise_error(Sequel::Error) + @i.this.update(:number=>1, :cost=>3) + proc{@i.save}.should raise_error(Sequel::Error) + @i.this.update(:cost=>2) + @i.save + @i.refresh.name.should == 'K' + end + + specify "should clear instance filters after successful save" do + @i.save + @i.this.update(:number=>2) + @i.update(:name=>'L') + @i.refresh.name.should == 'L' + end + + specify "should not raise an error if deleting only deletes one row" do + @i.destroy + proc{@i.refresh}.should raise_error(Sequel::Error, 'Record not found') + end + + specify "should raise error if destroying doesn't delete a row" do + @i.this.update(:number=>2) + proc{@i.destroy}.should raise_error(Sequel::Error) + end +end diff --git a/www/pages/plugins b/www/pages/plugins index 8995fa8d71..53e19bb71a 100644 --- a/www/pages/plugins +++ b/www/pages/plugins @@ -15,7 +15,8 @@
  • 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.
  • -
  • instance_hooks: Allow you to add hooks to specific model instances.
  • +
  • instance_filters: Allows you to add per instance filters that are used when updating or destroying the instance.
  • +
  • instance_hooks: Allows you to add hooks to specific model instances.
  • lazy_attributes: Allows you to set some attributes that should not be loaded by default, but only loaded when an object requests them.
  • many_through_many: Allow you to create an association to multiple objects through multiple join tables.
  • nested_attributes: Allow you to modified associated objects directly through a model object, similar to ActiveRecord's Nested Attributes.