diff --git a/lib/couchrest/mixins.rb b/lib/couchrest/mixins.rb index 6a5198e..06c44da 100644 --- a/lib/couchrest/mixins.rb +++ b/lib/couchrest/mixins.rb @@ -1,3 +1,4 @@ mixins_dir = File.join(File.dirname(__FILE__), 'mixins') -require File.join(mixins_dir, 'attachments') \ No newline at end of file +require File.join(mixins_dir, 'attachments') +require File.join(mixins_dir, 'callbacks') \ No newline at end of file diff --git a/lib/couchrest/mixins/callbacks.rb b/lib/couchrest/mixins/callbacks.rb new file mode 100644 index 0000000..8165422 --- /dev/null +++ b/lib/couchrest/mixins/callbacks.rb @@ -0,0 +1,440 @@ +# Extracted from ActiveSupport::Callbacks written by Yehuda Katz +# http://github.com/wycats/rails/raw/18b405f154868204a8f332888871041a7bad95e1/activesupport/lib/active_support/callbacks.rb + +module CouchRest + # Callbacks are hooks into the lifecycle of an object that allow you to trigger logic + # before or after an alteration of the object state. + # + # Mixing in this module allows you to define callbacks in your class. + # + # Example: + # class Storage + # include CouchRest::Callbacks + # + # define_callbacks :save + # end + # + # class ConfigStorage < Storage + # save_callback :before, :saving_message + # def saving_message + # puts "saving..." + # end + # + # save_callback :after do |object| + # puts "saved" + # end + # + # def save + # _run_save_callbacks do + # puts "- save" + # end + # end + # end + # + # config = ConfigStorage.new + # config.save + # + # Output: + # saving... + # - save + # saved + # + # Callbacks from parent classes are inherited. + # + # Example: + # class Storage + # include CouchRest::Callbacks + # + # define_callbacks :save + # + # save_callback :before, :prepare + # def prepare + # puts "preparing save" + # end + # end + # + # class ConfigStorage < Storage + # save_callback :before, :saving_message + # def saving_message + # puts "saving..." + # end + # + # save_callback :after do |object| + # puts "saved" + # end + # + # def save + # _run_save_callbacks do + # puts "- save" + # end + # end + # end + # + # config = ConfigStorage.new + # config.save + # + # Output: + # preparing save + # saving... + # - save + # saved + module Callbacks + def self.included(klass) + klass.extend ClassMethods + end + + def run_callbacks(kind, options = {}) + send("_run_#{kind}_callbacks") + end + + class Callback + @@_callback_sequence = 0 + + attr_accessor :filter, :kind, :name, :options, :per_key, :klass + def initialize(filter, kind, options, klass, name) + @kind, @klass = kind, klass + @name = name + + normalize_options!(options) + + @per_key = options.delete(:per_key) + @raw_filter, @options = filter, options + @filter = _compile_filter(filter) + @compiled_options = _compile_options(options) + @callback_id = next_id + + _compile_per_key_options + end + + def clone(klass) + obj = super() + obj.klass = klass + obj.per_key = @per_key.dup + obj.options = @options.dup + obj.per_key[:if] = @per_key[:if].dup + obj.per_key[:unless] = @per_key[:unless].dup + obj.options[:if] = @options[:if].dup + obj.options[:unless] = @options[:unless].dup + obj + end + + def normalize_options!(options) + options[:if] = Array(options[:if]) + options[:unless] = Array(options[:unless]) + + options[:per_key] ||= {} + options[:per_key][:if] = Array(options[:per_key][:if]) + options[:per_key][:unless] = Array(options[:per_key][:unless]) + end + + def next_id + @@_callback_sequence += 1 + end + + def matches?(_kind, _name, _filter) + @kind == _kind && + @name == _name && + @filter == _filter + end + + def _update_filter(filter_options, new_options) + filter_options[:if].push(new_options[:unless]) if new_options.key?(:unless) + filter_options[:unless].push(new_options[:if]) if new_options.key?(:if) + end + + def recompile!(_options, _per_key) + _update_filter(self.options, _options) + _update_filter(self.per_key, _per_key) + + @callback_id = next_id + @filter = _compile_filter(@raw_filter) + @compiled_options = _compile_options(@options) + _compile_per_key_options + end + + def _compile_per_key_options + key_options = _compile_options(@per_key) + + @klass.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def _one_time_conditions_valid_#{@callback_id}? + true #{key_options[0]} + end + RUBY_EVAL + end + + # This will supply contents for before and around filters, and no + # contents for after filters (for the forward pass). + def start(key = nil, object = nil) + return if key && !object.send("_one_time_conditions_valid_#{@callback_id}?") + + # options[0] is the compiled form of supplied conditions + # options[1] is the "end" for the conditional + + if @kind == :before || @kind == :around + if @kind == :before + # if condition # before_save :filter_name, :if => :condition + # filter_name + # end + [@compiled_options[0], @filter, @compiled_options[1]].compact.join("\n") + elsif @compiled_options[0] + # Compile around filters with conditions into proxy methods + # that contain the conditions. + # + # For `around_save :filter_name, :if => :condition': + # + # def _conditional_callback_save_17 + # if condition + # filter_name do + # yield self + # end + # else + # yield self + # end + # end + + name = "_conditional_callback_#{@kind}_#{next_id}" + txt = <<-RUBY_EVAL + def #{name} + #{@compiled_options[0]} + #{@filter} do + yield self + end + else + yield self + end + end + RUBY_EVAL + @klass.class_eval(txt) + "#{name} do" + else + "#{@filter} do" + end + end + end + + # This will supply contents for around and after filters, but not + # before filters (for the backward pass). + def end(key = nil, object = nil) + return if key && !object.send("_one_time_conditions_valid_#{@callback_id}?") + + if @kind == :around || @kind == :after + # if condition # after_save :filter_name, :if => :condition + # filter_name + # end + if @kind == :after + [@compiled_options[0], @filter, @compiled_options[1]].compact.join("\n") + else + "end" + end + end + end + + private + # Options support the same options as filters themselves (and support + # symbols, string, procs, and objects), so compile a conditional + # expression based on the options + def _compile_options(options) + return [] if options[:if].empty? && options[:unless].empty? + + conditions = [] + + unless options[:if].empty? + conditions << Array(_compile_filter(options[:if])) + end + + unless options[:unless].empty? + conditions << Array(_compile_filter(options[:unless])).map {|f| "!#{f}"} + end + + ["if #{conditions.flatten.join(" && ")}", "end"] + end + + # Filters support: + # Arrays:: Used in conditions. This is used to specify + # multiple conditions. Used internally to + # merge conditions from skip_* filters + # Symbols:: A method to call + # Strings:: Some content to evaluate + # Procs:: A proc to call with the object + # Objects:: An object with a before_foo method on it to call + # + # All of these objects are compiled into methods and handled + # the same after this point: + # Arrays:: Merged together into a single filter + # Symbols:: Already methods + # Strings:: class_eval'ed into methods + # Procs:: define_method'ed into methods + # Objects:: + # a method is created that calls the before_foo method + # on the object. + def _compile_filter(filter) + method_name = "_callback_#{@kind}_#{next_id}" + case filter + when Array + filter.map {|f| _compile_filter(f)} + when Symbol + filter + when Proc + @klass.send(:define_method, method_name, &filter) + method_name << (filter.arity == 1 ? "(self)" : "") + when String + @klass.class_eval <<-RUBY_EVAL + def #{method_name} + #{filter} + end + RUBY_EVAL + method_name + else + kind, name = @kind, @name + @klass.send(:define_method, method_name) do + filter.send("#{kind}_#{name}", self) + end + method_name + end + end + end + + # This method_missing is supplied to catch callbacks with keys and create + # the appropriate callback for future use. + def method_missing(meth, *args, &blk) + if meth.to_s =~ /_run_(\w+)_(\w+)_(\w+)_callbacks/ + return self.class._create_and_run_keyed_callback($1, $2.to_sym, $3.to_sym, self, &blk) + end + super + end + + # An Array with a compile method + class CallbackChain < Array + def compile(key = nil, object = nil) + method = [] + each do |callback| + method << callback.start(key, object) + end + method << "yield self" + reverse_each do |callback| + method << callback.end(key, object) + end + method.compact.join("\n") + end + + def clone(klass) + CallbackChain.new(map {|c| c.clone(klass)}) + end + end + + module ClassMethods + CHAINS = {:before => :before, :around => :before, :after => :after} + + # Make the _run_save_callbacks method. The generated method takes + # a block that it'll yield to. It'll call the before and around filters + # in order, yield the block, and then run the after filters. + # + # _run_save_callbacks do + # save + # end + # + # The _run_save_callbacks method can optionally take a key, which + # will be used to compile an optimized callback method for each + # key. See #define_callbacks for more information. + def _define_runner(symbol, str, options) + self.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def _run_#{symbol}_callbacks(key = nil) + if key + send("_run_\#{self.class}_#{symbol}_\#{key}_callbacks") { yield } + else + #{str} + end + end + RUBY_EVAL + + before_name, around_name, after_name = + options.values_at(:before, :after, :around) + end + + # This is called the first time a callback is called with a particular + # key. It creates a new callback method for the key, calculating + # which callbacks can be omitted because of per_key conditions. + def _create_and_run_keyed_callback(klass, kind, key, obj, &blk) + @_keyed_callbacks ||= {} + @_keyed_callbacks[[kind, key]] ||= begin + str = self.send("_#{kind}_callbacks").compile(key, obj) + self.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def _run_#{klass}_#{kind}_#{key}_callbacks + #{str} + end + RUBY_EVAL + true + end + obj.send("_run_#{klass}_#{kind}_#{key}_callbacks", &blk) + end + + # Define callbacks. + # + # Creates a _callback method that you can use to add callbacks. + # + # Syntax: + # save_callback :before, :before_meth + # save_callback :after, :after_meth, :if => :condition + # save_callback :around {|r| stuff; yield; stuff } + # + # The _callback method also updates the _run__callbacks + # method, which is the public API to run the callbacks. + # + # Also creates a skip__callback method that you can use to skip + # callbacks. + # + # When creating or skipping callbacks, you can specify conditions that + # are always the same for a given key. For instance, in ActionPack, + # we convert :only and :except conditions into per-key conditions. + # + # before_filter :authenticate, :except => "index" + # becomes + # dispatch_callback :before, :authenticate, :per_key => {:unless => proc {|c| c.action_name == "index"}} + # + # Per-Key conditions are evaluated only once per use of a given key. + # In the case of the above example, you would do: + # + # run_dispatch_callbacks(action_name) { ... dispatch stuff ... } + # + # In that case, each action_name would get its own compiled callback + # method that took into consideration the per_key conditions. This + # is a speed improvement for ActionPack. + def define_callbacks(*symbols) + symbols.each do |symbol| + self.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + class_inheritable_accessor :_#{symbol}_callbacks + self._#{symbol}_callbacks = CallbackChain.new + + def self.#{symbol}_callback(type, *filters, &blk) + options = filters.last.is_a?(Hash) ? filters.pop : {} + filters.unshift(blk) if block_given? + filters.map! {|f| Callback.new(f, type, options.dup, self, :#{symbol})} + self._#{symbol}_callbacks.push(*filters) + _define_runner(:#{symbol}, self._#{symbol}_callbacks.compile, options) + end + + def self.skip_#{symbol}_callback(type, *filters, &blk) + options = filters.last.is_a?(Hash) ? filters.pop : {} + filters.unshift(blk) if block_given? + filters.each do |filter| + self._#{symbol}_callbacks = self._#{symbol}_callbacks.clone(self) + + filter = self._#{symbol}_callbacks.find {|c| c.matches?(type, :#{symbol}, filter) } + per_key = options[:per_key] || {} + if filter + filter.recompile!(options, per_key) + else + self._#{symbol}_callbacks.delete(filter) + end + _define_runner(:#{symbol}, self._#{symbol}_callbacks.compile, options) + end + + end + + self.#{symbol}_callback(:before) + RUBY_EVAL + end + end + end + end +end diff --git a/lib/couchrest/mixins/validation.rb b/lib/couchrest/mixins/validation.rb index 14f9c52..17797ef 100644 --- a/lib/couchrest/mixins/validation.rb +++ b/lib/couchrest/mixins/validation.rb @@ -21,9 +21,6 @@ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -require 'rubygems' -require 'pathname' - class Object def validatable? false @@ -48,8 +45,20 @@ module CouchRest def self.included(base) base.extend(ClassMethods) + base.class_eval <<-EOS, __FILE__, __LINE__ + if (method_defined?(:save) && method_defined?(:_run_save_callbacks)) + save_callback :before, :check_validations + end + EOS end + # Ensures the object is valid for the context provided, and otherwise + # throws :halt and returns false. + # + def check_validations(context = :default) + throw(:halt, false) unless context.nil? || valid?(context) + end + # Return the ValidationErrors # def errors @@ -128,7 +137,7 @@ module CouchRest # include CouchRest::Validation::ValidatesWithBlock # include CouchRest::Validation::ValidatesIsUnique # include CouchRest::Validation::AutoValidate - + # Return the set of contextual validators or create a new one # def validators diff --git a/lib/couchrest/more/extended_document.rb b/lib/couchrest/more/extended_document.rb index 1e13cfb..e4615b6 100644 --- a/lib/couchrest/more/extended_document.rb +++ b/lib/couchrest/more/extended_document.rb @@ -1,11 +1,3 @@ -require 'rubygems' -begin - gem 'extlib' - require 'extlib' -rescue - puts "CouchRest::Model requires extlib. This is left out of the gemspec on purpose." - raise -end require 'mime/types' require File.join(File.dirname(__FILE__), "property") require File.join(File.dirname(__FILE__), '..', 'mixins', 'extended_document_mixins') @@ -18,16 +10,25 @@ module CouchRest include CouchRest::Mixins::DocumentProperties include CouchRest::Mixins::Views include CouchRest::Mixins::DesignDoc - + include CouchRest::Callbacks + + # Callbacks + define_callbacks :save + define_callbacks :destroy # Automatically set updated_at and created_at fields # on the document whenever saving occurs. CouchRest uses a pretty # decent time format by default. See Time#to_json def self.timestamps! - before(:save) do - self['updated_at'] = Time.now - self['created_at'] = self['updated_at'] if new_document? - end + class_eval <<-EOS, __FILE__, __LINE__ + property(:updated_at, :read_only => true) + property(:created_at, :read_only => true) + + save_callback :before do |object| + object['updated_at'] = Time.now + object['created_at'] = object['updated_at'] if object.new_document? + end + EOS end # Name a method that will be called before the document is first saved, @@ -84,9 +85,19 @@ module CouchRest # for compatibility with old-school frameworks alias :new_record? :new_document? + # Trigger the callbacks (before, after, around) + # and save the document + def save(bulk = false) + caught = catch(:halt) do + _run_save_callbacks do + save_without_callbacks(bulk) + end + end + end + # Overridden to set the unique ID. # Returns a boolean value - def save(bulk = false) + def save_without_callbacks(bulk = false) set_unique_id if new_document? && self.respond_to?(:set_unique_id) result = database.save_doc(self, bulk) result["ok"] == true @@ -102,13 +113,17 @@ module CouchRest # Removes the _id and _rev fields, preparing the # document to be saved to a new _id. def destroy - result = database.delete_doc self - if result['ok'] - self['_rev'] = nil - self['_id'] = nil + caught = catch(:halt) do + _run_destroy_callbacks do + result = database.delete_doc self + if result['ok'] + self['_rev'] = nil + self['_id'] = nil + end + result['ok'] + end end - result['ok'] end - + end end \ No newline at end of file diff --git a/lib/couchrest/validation/contextual_validators.rb b/lib/couchrest/validation/contextual_validators.rb index 5c00baa..9f2304c 100644 --- a/lib/couchrest/validation/contextual_validators.rb +++ b/lib/couchrest/validation/contextual_validators.rb @@ -1,3 +1,26 @@ +# Extracted from dm-validations 0.9.10 +# +# Copyright (c) 2007 Guy van den Berg +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + module CouchRest module Validation diff --git a/lib/couchrest/validation/validators/absent_field_validator.rb b/lib/couchrest/validation/validators/absent_field_validator.rb index 3ba4b9d..e2b7f55 100644 --- a/lib/couchrest/validation/validators/absent_field_validator.rb +++ b/lib/couchrest/validation/validators/absent_field_validator.rb @@ -50,7 +50,6 @@ module CouchRest ## # # @example [Usage] - # require 'dm-validations' # # class Page # diff --git a/lib/couchrest/validation/validators/method_validator.rb b/lib/couchrest/validation/validators/method_validator.rb index 4bc4527..d393fc9 100644 --- a/lib/couchrest/validation/validators/method_validator.rb +++ b/lib/couchrest/validation/validators/method_validator.rb @@ -1,3 +1,26 @@ +# Extracted from dm-validations 0.9.10 +# +# Copyright (c) 2007 Guy van den Berg +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + module CouchRest module Validation diff --git a/lib/couchrest/validation/validators/numeric_validator.rb b/lib/couchrest/validation/validators/numeric_validator.rb index 9410102..47ce05b 100644 --- a/lib/couchrest/validation/validators/numeric_validator.rb +++ b/lib/couchrest/validation/validators/numeric_validator.rb @@ -1,3 +1,26 @@ +# Extracted from dm-validations 0.9.10 +# +# Copyright (c) 2007 Guy van den Berg +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + module CouchRest module Validation diff --git a/lib/couchrest/validation/validators/required_field_validator.rb b/lib/couchrest/validation/validators/required_field_validator.rb index e68b009..e30fa83 100644 --- a/lib/couchrest/validation/validators/required_field_validator.rb +++ b/lib/couchrest/validation/validators/required_field_validator.rb @@ -1,3 +1,26 @@ +# Extracted from dm-validations 0.9.10 +# +# Copyright (c) 2007 Guy van den Berg +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + module CouchRest module Validation diff --git a/spec/couchrest/more/property_spec.rb b/spec/couchrest/more/property_spec.rb index 1eaec4e..0046bb5 100644 --- a/spec/couchrest/more/property_spec.rb +++ b/spec/couchrest/more/property_spec.rb @@ -34,6 +34,14 @@ describe "ExtendedDocument properties" do @card.family_name.should == @card.last_name end + it "should be auto timestamped" do + @card.created_at.should be_nil + @card.updated_at.should be_nil + @card.save + @card.created_at.should_not be_nil + @card.updated_at.should_not be_nil + end + describe "validation" do before(:each) do @@ -64,6 +72,13 @@ describe "ExtendedDocument properties" do @invoice.valid? @invoice.errors.on(:location).first.should == "Hey stupid!, you forgot the location" end + + it "should validate before saving" do + @invoice.location = nil + @invoice.should_not be_valid + @invoice.save.should be_false + @invoice.should be_new_document + end end diff --git a/spec/fixtures/more/card.rb b/spec/fixtures/more/card.rb index f83c280..700e357 100644 --- a/spec/fixtures/more/card.rb +++ b/spec/fixtures/more/card.rb @@ -10,6 +10,8 @@ class Card < CouchRest::ExtendedDocument property :last_name, :alias => :family_name property :read_only_value, :read_only => true + timestamps! + # Validation validates_present :first_name