Skip to content

Commit

Permalink
Add instance_filters plugin, for adding arbitrary filters when updati…
Browse files Browse the repository at this point in the history
…ng/destroying the instance

This plugin is 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.
  • Loading branch information
jeremyevans committed Apr 9, 2010
1 parent 684232e commit 0c42c4c
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 1 deletion.
2 changes: 2 additions & 0 deletions 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)
Expand Down
102 changes: 102 additions & 0 deletions 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
55 changes: 55 additions & 0 deletions 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
60 changes: 60 additions & 0 deletions spec/integration/plugin_test.rb
Expand Up @@ -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
3 changes: 2 additions & 1 deletion www/pages/plugins
Expand Up @@ -15,7 +15,8 @@
<li><a href="rdoc-plugins/classes/Sequel/Plugins/ForceEncoding.html">force_encoding</a>: Forces the all model column string values to a given encoding.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/HookClassMethods.html">hook_class_methods</a>: Adds backwards compatiblity for the legacy class-level hook methods (e.g. before_save :do_something).</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/IdentityMap.html">identity_map</a>: Allows you to create temporary identity maps which ensure a 1-1 correspondence of model objects to database rows.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/InstanceHooks.html">instance_hooks</a>: Allow you to add hooks to specific model instances.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/InstanceFilters.html">instance_filters</a>: Allows you to add per instance filters that are used when updating or destroying the instance.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/InstanceHooks.html">instance_hooks</a>: Allows you to add hooks to specific model instances.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/LazyAttributes.html">lazy_attributes</a>: Allows you to set some attributes that should not be loaded by default, but only loaded when an object requests them.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/ManyThroughMany.html">many_through_many</a>: Allow you to create an association to multiple objects through multiple join tables.</li>
<li><a href="rdoc-plugins/classes/Sequel/Plugins/NestedAttributes.html">nested_attributes</a>: Allow you to modified associated objects directly through a model object, similar to ActiveRecord's Nested Attributes.</li>
Expand Down

0 comments on commit 0c42c4c

Please sign in to comment.