diff --git a/README.md b/README.md index 5692d35..d13822d 100644 --- a/README.md +++ b/README.md @@ -103,24 +103,35 @@ Check spec/couchrest/more and spec/fixtures/more for more examples save_callback :before, :generate_slug_from_title def generate_slug_from_title - self['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') if new_document? + self['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') if new? end end ### Callbacks -`CouchRest::ExtendedDocuments` instances have 2 callbacks already defined for you: - `create_callback`, `save_callback`, `update_callback` and `destroy_callback` +`CouchRest::ExtendedDocuments` instances have 4 callbacks already defined for you: + `:validate`, `:create`, `:save`, `:update` and `:destroy` -In your document inherits from `CouchRest::ExtendedDocument`, define your callback as follows: +`CouchRest::CastedModel` instances have 1 callback already defined for you: + `:validate` + +Define your callback as follows: - save_callback :before, :generate_slug_from_name + set_callback :save, :before, :generate_slug_from_name CouchRest uses a mixin you can find in lib/mixins/callbacks which is extracted from Rails 3, here are some simple usage examples: - save_callback :before, :before_method - save_callback :after, :after_method, :if => :condition - save_callback :around {|r| stuff; yield; stuff } + set_callback :save, :before, :before_method + set_callback :save, :after, :after_method, :if => :condition + set_callback :save, :around {|r| stuff; yield; stuff } + + Or the aliased short version: + + before_save :before_method, :another_method + after_save :after_method, :another_method, :if => :condition + around_save {|r| stuff; yield; stuff } + +To halt the callback, simply return a :halt symbol in your callback method. Check the mixin or the ExtendedDocument class to see how to implement your own callbacks. @@ -162,4 +173,4 @@ Low level usage: CouchRest is compatible with rails and can even be used a Rails plugin. However, you might be interested in the CouchRest companion rails project: -[http://github.com/hpoydar/couchrest-rails](http://github.com/hpoydar/couchrest-rails) \ No newline at end of file +[http://github.com/hpoydar/couchrest-rails](http://github.com/hpoydar/couchrest-rails) diff --git a/couchrest.gemspec b/couchrest.gemspec index 26fa2a0..a2a01da 100644 --- a/couchrest.gemspec +++ b/couchrest.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |s| s.name = %q{couchrest} - s.version = "0.33" + s.version = "0.34" s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= s.authors = ["J. Chris Anderson", "Matt Aimonetti"] diff --git a/lib/couchrest.rb b/lib/couchrest.rb index 3da42d8..e662a42 100644 --- a/lib/couchrest.rb +++ b/lib/couchrest.rb @@ -48,6 +48,7 @@ module CouchRest require File.join(File.dirname(__FILE__), 'couchrest', 'core', 'rest_api') require File.join(File.dirname(__FILE__), 'couchrest', 'core', 'http_abstraction') require File.join(File.dirname(__FILE__), 'couchrest', 'mixins') + require File.join(File.dirname(__FILE__), 'couchrest', 'support', 'rails') if defined?(Rails) # we extend CouchRest with the RestAPI module which gives us acess to # the get, post, put, delete and copy diff --git a/lib/couchrest/core/document.rb b/lib/couchrest/core/document.rb index 44c4f5a..cd66285 100644 --- a/lib/couchrest/core/document.rb +++ b/lib/couchrest/core/document.rb @@ -23,9 +23,10 @@ def rev end # returns true if the document has never been saved - def new_document? + def new? !rev end + alias :new_document? :new? # Saves the document to the db using create or update. Also runs the :save # callbacks. Sets the _id and _rev fields based on @@ -63,7 +64,7 @@ def copy(dest) # Returns the CouchDB uri for the document def uri(append_rev = false) - return nil if new_document? + return nil if new? couch_uri = "http://#{database.root}/#{CGI.escape(id)}" if append_rev == true couch_uri << "?rev=#{rev}" diff --git a/lib/couchrest/mixins/callbacks.rb b/lib/couchrest/mixins/callbacks.rb index 2516702..e302ad7 100644 --- a/lib/couchrest/mixins/callbacks.rb +++ b/lib/couchrest/mixins/callbacks.rb @@ -1,8 +1,29 @@ -require File.join(File.dirname(__FILE__), '..', 'support', 'class') +# Copyright (c) 2006-2009 David Heinemeier Hansson +# +# 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. +# +# Extracted from ActiveSupport::NewCallbacks written by Yehuda Katz +# http://github.com/rails/rails/raw/d6e4113c83a9d55be6f2af247da2cecaa855f43b/activesupport/lib/active_support/new_callbacks.rb +# http://github.com/rails/rails/commit/1126a85aed576402d978e6f76eb393b6baaa9541 -# Extracted from ActiveSupport::Callbacks written by Yehuda Katz -# http://github.com/wycats/rails/raw/abstract_controller/activesupport/lib/active_support/new_callbacks.rb -# http://github.com/wycats/rails/raw/18b405f154868204a8f332888871041a7bad95e1/activesupport/lib/active_support/callbacks.rb +require File.join(File.dirname(__FILE__), '..', 'support', 'class') module CouchRest # Callbacks are hooks into the lifecycle of an object that allow you to trigger logic @@ -85,19 +106,18 @@ module Callbacks def self.included(klass) klass.extend ClassMethods end - + def run_callbacks(kind, options = {}, &blk) send("_run_#{kind}_callbacks", &blk) end - + class Callback @@_callback_sequence = 0 - + attr_accessor :filter, :kind, :name, :options, :per_key, :klass - def initialize(filter, kind, options, klass, name) + def initialize(filter, kind, options, klass) @kind, @klass = kind, klass - @name = name - + normalize_options!(options) @per_key = options.delete(:per_key) @@ -108,7 +128,7 @@ def initialize(filter, kind, options, klass, name) _compile_per_key_options end - + def clone(klass) obj = super() obj.klass = klass @@ -120,23 +140,22 @@ def clone(klass) obj.options[:unless] = @options[:unless].dup obj end - + def normalize_options!(options) - options[:if] = Array(options[:if]) - options[:unless] = Array(options[:unless]) + options[:if] = Array.wrap(options[:if]) + options[:unless] = Array.wrap(options[:unless]) options[:per_key] ||= {} - options[:per_key][:if] = Array(options[:per_key][:if]) - options[:per_key][:unless] = Array(options[:per_key][:unless]) + options[:per_key][:if] = Array.wrap(options[:per_key][:if]) + options[:per_key][:unless] = Array.wrap(options[:per_key][:unless]) end - + def next_id @@_callback_sequence += 1 end - - def matches?(_kind, _name, _filter) + + def matches?(_kind, _filter) @kind == _kind && - @name == _name && @filter == _filter end @@ -144,11 +163,11 @@ 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) @@ -164,19 +183,19 @@ def _one_time_conditions_valid_#{@callback_id}? 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, options = {}) object, terminator = (options || {}).values_at(:object, :terminator) - + return if key && !object.send("_one_time_conditions_valid_#{@callback_id}?") - + terminator ||= false - + # 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 @@ -185,9 +204,10 @@ def start(key = nil, options = {}) filter = <<-RUBY_EVAL unless halted result = #{@filter} - halted ||= (#{terminator}) + halted = (#{terminator}) end RUBY_EVAL + [@compiled_options[0], filter, @compiled_options[1]].compact.join("\n") else # Compile around filters with conditions into proxy methods @@ -204,9 +224,9 @@ def start(key = nil, options = {}) # yield self # end # end - + name = "_conditional_callback_#{@kind}_#{next_id}" - txt = <<-RUBY_EVAL + txt, line = <<-RUBY_EVAL, __LINE__ + 1 def #{name}(halted) #{@compiled_options[0] || "if true"} && !halted #{@filter} do @@ -217,19 +237,19 @@ def #{name}(halted) end end RUBY_EVAL - @klass.class_eval(txt) + @klass.class_eval(txt, __FILE__, line) "#{name}(halted) 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, options = {}) object = (options || {})[:object] - + 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 @@ -241,27 +261,27 @@ def end(key = nil, options = {}) 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])) + conditions << Array.wrap(_compile_filter(options[:if])) end - + unless options[:unless].empty? - conditions << Array(_compile_filter(options[:unless])).map {|f| "!#{f}"} + conditions << Array.wrap(_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 @@ -287,63 +307,72 @@ def _compile_filter(filter) filter.map {|f| _compile_filter(f)} when Symbol filter + when String + "(#{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} + return method_name if filter.arity == 0 + + method_name << (filter.arity == 1 ? "(self)" : " self, Proc.new ") + else + @klass.send(:define_method, "#{method_name}_object") { filter } + + _normalize_legacy_filter(kind, filter) + + @klass.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def #{method_name}(&blk) + #{method_name}_object.send(:#{kind}, self, &blk) 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) + def _normalize_legacy_filter(kind, filter) + if !filter.respond_to?(kind) && filter.respond_to?(:filter) + filter.class_eval( + "def #{kind}(context, &block) filter(context, &block) end", + __FILE__, __LINE__ - 1) + elsif filter.respond_to?(:before) && filter.respond_to?(:after) && kind == :around + def filter.around(context) + should_continue = before(context) + yield if should_continue + after(context) + end + end end - super + end - + # An Array with a compile method class CallbackChain < Array def initialize(symbol) @symbol = symbol end - + def compile(key = nil, options = {}) method = [] method << "halted = false" each do |callback| method << callback.start(key, options) end - method << "yield self if block_given?" + method << "yield self if block_given? && !halted" reverse_each do |callback| method << callback.end(key, options) end method.compact.join("\n") end - + def clone(klass) chain = CallbackChain.new(@symbol) chain.push(*map {|c| c.clone(klass)}) end end - + module ClassMethods - CHAINS = {:before => :before, :around => :before, :after => :after} unless self.const_defined?("CHAINS") - + #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. @@ -355,43 +384,45 @@ module ClassMethods # 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) - str = <<-RUBY_EVAL - def _run_#{symbol}_callbacks(key = nil) + def _define_runner(symbol) + body = send("_#{symbol}_callback"). + compile(nil, :terminator => send("_#{symbol}_terminator")) + + body, line = <<-RUBY_EVAL, __LINE__ + 1 + def _run_#{symbol}_callbacks(key = nil, &blk) if key - send("_run__\#{self.class.name.split("::").last}__#{symbol}__\#{key}__callbacks") { yield if block_given? } + name = "_run__\#{self.class.name.hash.abs}__#{symbol}__\#{key.hash.abs}__callbacks" + + unless respond_to?(name) + self.class._create_keyed_callback(name, :#{symbol}, self, &blk) + end + + send(name, &blk) else - #{str} + #{body} end end RUBY_EVAL - - class_eval str, __FILE__, __LINE__ + 1 - - before_name, around_name, after_name = - options.values_at(:before, :after, :around) + + undef_method "_run_#{symbol}_callbacks" if method_defined?("_run_#{symbol}_callbacks") + class_eval body, __FILE__, line 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) + def _create_keyed_callback(name, kind, obj, &blk) @_keyed_callbacks ||= {} - @_keyed_callbacks[[kind, key]] ||= begin - str = self.send("_#{kind}_callbacks").compile(key, :object => obj, :terminator => self.send("_#{kind}_terminator")) + @_keyed_callbacks[name] ||= begin + str = send("_#{kind}_callback"). + compile(name, :object => obj, :terminator => send("_#{kind}_terminator")) + + class_eval "def #{name}() #{str} end", __FILE__, __LINE__ - self.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 - def _run__#{klass.split("::").last}__#{kind}__#{key}__callbacks - #{str} - end - RUBY_EVAL - true end - - obj.send("_run__#{klass.split("::").last}__#{kind}__#{key}__callbacks", &blk) end - + # Define callbacks. # # Creates a _callback method that you can use to add callbacks. @@ -423,59 +454,77 @@ def _run__#{klass.split("::").last}__#{kind}__#{key}__callbacks # 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 _update_callbacks(name, filters = CallbackChain.new(name), block = nil) + type = [:before, :after, :around].include?(filters.first) ? filters.shift : :before + options = filters.last.is_a?(Hash) ? filters.pop : {} + filters.unshift(block) if block + + callbacks = send("_#{name}_callback") + yield callbacks, type, filters, options if block_given? + + _define_runner(name) + end + + alias_method :_reset_callbacks, :_update_callbacks + + def set_callback(name, *filters, &block) + _update_callbacks(name, filters, block) do |callbacks, type, filters, options| + filters.map! do |filter| + # overrides parent class + callbacks.delete_if {|c| c.matches?(type, filter) } + Callback.new(filter, type, options.dup, self) + end + + options[:prepend] ? callbacks.unshift(*filters) : callbacks.push(*filters) + end + end + + def skip_callback(name, *filters, &block) + _update_callbacks(name, filters, block) do |callbacks, type, filters, options| + filters.each do |filter| + callbacks = send("_#{name}_callback=", callbacks.clone(self)) + + filter = callbacks.find {|c| c.matches?(type, filter) } + + if filter && options.any? + filter.recompile!(options, options[:per_key] || {}) + else + callbacks.delete(filter) + end + end + end + end + def define_callbacks(*symbols) terminator = symbols.pop if symbols.last.is_a?(String) symbols.each do |symbol| - self.extlib_inheritable_accessor("_#{symbol}_terminator") - self.send("_#{symbol}_terminator=", terminator) - self.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 - extlib_inheritable_accessor :_#{symbol}_callbacks - self._#{symbol}_callbacks = CallbackChain.new(:#{symbol}) - - def self.#{symbol}_callback(*filters, &blk) - type = [:before, :after, :around].include?(filters.first) ? filters.shift : :before - options = filters.last.is_a?(Hash) ? filters.pop : {} - filters.unshift(blk) if block_given? - - filters.map! do |filter| - # overrides parent class - self._#{symbol}_callbacks.delete_if {|c| c.matches?(type, :#{symbol}, filter)} - Callback.new(filter, type, options.dup, self, :#{symbol}) - end - self._#{symbol}_callbacks.push(*filters) - _define_runner(:#{symbol}, - self._#{symbol}_callbacks.compile(nil, :terminator => _#{symbol}_terminator), - options) - end - - def self.skip_#{symbol}_callback(*filters, &blk) - type = [:before, :after, :around].include?(filters.first) ? filters.shift : :before - 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) + extlib_inheritable_accessor("_#{symbol}_terminator") { terminator } + + extlib_inheritable_accessor("_#{symbol}_callback") do + CallbackChain.new(symbol) + end + + _define_runner(symbol) + + # Define more convenient callback methods + # set_callback(:save, :before) becomes before_save + [:before, :after, :around].each do |filter| + self.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 + def self.#{filter}_#{symbol}(*symbols, &blk) + _alias_callbacks(symbols, blk) do |callback, options| + set_callback(:#{symbol}, :#{filter}, callback, options) end - _define_runner(:#{symbol}, - self._#{symbol}_callbacks.compile(nil, :terminator => _#{symbol}_terminator), - options) end - - end - - def self.reset_#{symbol}_callbacks - self._#{symbol}_callbacks = CallbackChain.new(:#{symbol}) - _define_runner(:#{symbol}, self._#{symbol}_callbacks.compile, {}) - end - - self.#{symbol}_callback(:before) - RUBY_EVAL + RUBY_EVAL + end + end + end + + def _alias_callbacks(callbacks, block) + options = callbacks.last.is_a?(Hash) ? callbacks.pop : {} + callbacks.push(block) if block + callbacks.each do |callback| + yield callback, options end end end diff --git a/lib/couchrest/mixins/properties.rb b/lib/couchrest/mixins/properties.rb index 69c9411..d0d8537 100644 --- a/lib/couchrest/mixins/properties.rb +++ b/lib/couchrest/mixins/properties.rb @@ -27,7 +27,7 @@ module Properties class IncludeError < StandardError; end def self.included(base) - base.class_eval <<-EOS, __FILE__, __LINE__ + base.class_eval <<-EOS, __FILE__, __LINE__ + 1 extlib_inheritable_accessor(:properties) unless self.respond_to?(:properties) self.properties ||= [] EOS @@ -36,7 +36,7 @@ def self.included(base) end def apply_defaults - return if self.respond_to?(:new_document?) && (new_document? == false) + return if self.respond_to?(:new?) && (new? == false) return unless self.class.respond_to?(:properties) return if self.class.properties.empty? # TODO: cache the default object @@ -56,50 +56,76 @@ def apply_defaults def cast_keys return unless self.class.properties self.class.properties.each do |property| - next unless property.casted - key = self.has_key?(property.name) ? property.name : property.name.to_sym - # Don't cast the property unless it has a value - next unless self[key] - target = property.type - if target.is_a?(Array) - klass = ::CouchRest.constantize(target[0]) - self[property.name] = self[key].collect do |value| - # Auto parse Time objects - obj = ( (property.init_method == 'new') && klass == Time) ? Time.parse(value) : klass.send(property.init_method, value) - obj.casted_by = self if obj.respond_to?(:casted_by) - obj + cast_property(property) + end + end + + def cast_property(property, assigned=false) + return unless property.casted + key = self.has_key?(property.name) ? property.name : property.name.to_sym + # Don't cast the property unless it has a value + return unless self[key] + if property.type.is_a?(Array) + klass = ::CouchRest.constantize(property.type[0]) + arr = self[key].dup.collect do |value| + unless value.instance_of?(klass) + value = convert_property_value(property, klass, value) end + associate_casted_to_parent(value, assigned) + value + end + self[key] = klass != String ? CastedArray.new(arr) : arr + self[key].casted_by = self if self[key].respond_to?(:casted_by) + else + if property.type == 'boolean' + klass = TrueClass else - # Auto parse Time objects - self[property.name] = if ((property.init_method == 'new') && target == 'Time') - # Using custom time parsing method because Ruby's default method is toooo slow - self[key].is_a?(String) ? Time.mktime_with_offset(self[key].dup) : self[key] - # Float instances don't get initialized with #new - elsif ((property.init_method == 'new') && target == 'Float') - cast_float(self[key]) - # 'boolean' type is simply used to generate a property? accessor method - elsif ((property.init_method == 'new') && target == 'boolean') - self[key] - else - # Let people use :send as a Time parse arg - klass = ::CouchRest.constantize(target) - klass.send(property.init_method, self[key].dup) - end - self[property.name].casted_by = self if self[property.name].respond_to?(:casted_by) - end + klass = ::CouchRest.constantize(property.type) + end - end - - def cast_float(value) - begin - Float(value) - rescue - value + unless self[key].instance_of?(klass) + self[key] = convert_property_value(property, klass, self[property.name]) end + associate_casted_to_parent(self[property.name], assigned) end end + def associate_casted_to_parent(casted, assigned) + casted.casted_by = self if casted.respond_to?(:casted_by) + casted.document_saved = true if !assigned && casted.respond_to?(:document_saved) + end + + def convert_property_value(property, klass, value) + if ((property.init_method == 'new') && klass == Time) + # Using custom time parsing method because Ruby's default method is toooo slow + value.is_a?(String) ? Time.mktime_with_offset(value.dup) : value + # Float instances don't get initialized with #new + elsif ((property.init_method == 'new') && klass == Float) + cast_float(value) + # 'boolean' type is simply used to generate a property? accessor method + elsif ((property.init_method == 'new') && klass == TrueClass) + value + else + klass.send(property.init_method, value.dup) + end + end + + def cast_property_by_name(property_name) + return unless self.class.properties + property = self.class.properties.detect{|property| property.name == property_name} + return unless property + cast_property(property, true) + end + + def cast_float(value) + begin + Float(value) + rescue + value + end + end + module ClassMethods def property(name, options={}) @@ -125,7 +151,7 @@ def define_property(name, options={}) # defines the getter for the property (and optional aliases) def create_property_getter(property) # meth = property.name - class_eval <<-EOS, __FILE__, __LINE__ + class_eval <<-EOS, __FILE__, __LINE__ + 1 def #{property.name} self['#{property.name}'] end @@ -144,7 +170,7 @@ def #{property.name}? end if property.alias - class_eval <<-EOS, __FILE__, __LINE__ + class_eval <<-EOS, __FILE__, __LINE__ + 1 alias #{property.alias.to_sym} #{property.name.to_sym} EOS end @@ -152,16 +178,17 @@ def #{property.name}? # defines the setter for the property (and optional aliases) def create_property_setter(property) - meth = property.name + property_name = property.name class_eval <<-EOS - def #{meth}=(value) - self['#{meth}'] = value + def #{property_name}=(value) + self['#{property_name}'] = value + cast_property_by_name('#{property_name}') end EOS if property.alias class_eval <<-EOS - alias #{property.alias.to_sym}= #{meth.to_sym}= + alias #{property.alias.to_sym}= #{property_name.to_sym}= EOS end end diff --git a/lib/couchrest/mixins/validation.rb b/lib/couchrest/mixins/validation.rb index 82c8ab5..094b70f 100644 --- a/lib/couchrest/mixins/validation.rb +++ b/lib/couchrest/mixins/validation.rb @@ -50,7 +50,10 @@ module Validation def self.included(base) base.extlib_inheritable_accessor(:auto_validation) - base.class_eval <<-EOS, __FILE__, __LINE__ + base.class_eval <<-EOS, __FILE__, __LINE__ + 1 + # Callbacks + define_callbacks :validate + # Turn off auto validation by default self.auto_validation ||= false @@ -71,9 +74,10 @@ def self.inherited(subklass) EOS base.extend(ClassMethods) - base.class_eval <<-EOS, __FILE__, __LINE__ + base.class_eval <<-EOS, __FILE__, __LINE__ + 1 + define_callbacks :validate if method_defined?(:_run_save_callbacks) - save_callback :before, :check_validations + set_callback :save, :before, :check_validations end EOS base.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 @@ -115,8 +119,7 @@ def valid_for_default? # Check if a resource is valid in a given context # def valid?(context = :default) - result = self.class.validators.execute(context, self) - result && validate_casted_arrays + recursive_valid?(self, context, true) end # checking on casted objects @@ -133,29 +136,24 @@ def validate_casted_arrays result end - # Begin a recursive walk of the model checking validity - # - def all_valid?(context = :default) - recursive_valid?(self, context, true) - end - # Do recursive validity checking # def recursive_valid?(target, context, state) valid = state - target.instance_variables.each do |ivar| - ivar_value = target.instance_variable_get(ivar) - if ivar_value.validatable? - valid = valid && recursive_valid?(ivar_value, context, valid) - elsif ivar_value.respond_to?(:each) - ivar_value.each do |item| + target.each do |key, prop| + if prop.is_a?(Array) + prop.each do |item| if item.validatable? - valid = valid && recursive_valid?(item, context, valid) + valid = recursive_valid?(item, context, valid) && valid end end + elsif prop.validatable? + valid = recursive_valid?(prop, context, valid) && valid end end - return valid && target.valid? + target._run_validate_callbacks do + target.class.validators.execute(context, target) && valid + end end @@ -212,21 +210,12 @@ def opts_from_validator_args(args, defaults = nil) def create_context_instance_methods(context) name = "valid_for_#{context.to_s}?" # valid_for_signup? if !self.instance_methods.include?(name) - class_eval <<-EOS, __FILE__, __LINE__ + class_eval <<-EOS, __FILE__, __LINE__ + 1 def #{name} # def valid_for_signup? valid?('#{context.to_s}'.to_sym) # valid?('signup'.to_sym) end # end EOS end - - all = "all_valid_for_#{context.to_s}?" # all_valid_for_signup? - if !self.instance_methods.include?(all) - class_eval <<-EOS, __FILE__, __LINE__ - def #{all} # def all_valid_for_signup? - all_valid?('#{context.to_s}'.to_sym) # all_valid?('signup'.to_sym) - end # end - EOS - end end # Create a new validator of the given klazz and push it onto the diff --git a/lib/couchrest/more/casted_model.rb b/lib/couchrest/more/casted_model.rb index 5b8d383..380a28f 100644 --- a/lib/couchrest/more/casted_model.rb +++ b/lib/couchrest/more/casted_model.rb @@ -5,8 +5,10 @@ module CouchRest module CastedModel def self.included(base) + base.send(:include, ::CouchRest::Callbacks) base.send(:include, ::CouchRest::Mixins::Properties) base.send(:attr_accessor, :casted_by) + base.send(:attr_accessor, :document_saved) end def initialize(keys={}) @@ -26,5 +28,31 @@ def []= key, value def [] key super(key.to_s) end + + # Gets a reference to the top level extended + # document that a model is saved inside of + def base_doc + return nil unless @casted_by + @casted_by.base_doc + end + + # False if the casted model has already + # been saved in the containing document + def new? + !@document_saved + end + alias :new_record? :new? + + # Sets the attributes from a hash + def update_attributes_without_saving(hash) + hash.each do |k, v| + raise NoMethodError, "#{k}= method not available, use property :#{k}" unless self.respond_to?("#{k}=") + end + hash.each do |k, v| + self.send("#{k}=",v) + end + end + alias :attributes= :update_attributes_without_saving + end -end \ No newline at end of file +end diff --git a/lib/couchrest/more/extended_document.rb b/lib/couchrest/more/extended_document.rb index d52ad9f..16798fe 100644 --- a/lib/couchrest/more/extended_document.rb +++ b/lib/couchrest/more/extended_document.rb @@ -21,7 +21,7 @@ def self.subclasses def self.inherited(subklass) subklass.send(:include, CouchRest::Mixins::Properties) - subklass.class_eval <<-EOS, __FILE__, __LINE__ + subklass.class_eval <<-EOS, __FILE__, __LINE__ + 1 def self.inherited(subklass) subklass.properties = self.properties.dup end @@ -33,10 +33,10 @@ def self.inherited(subklass) attr_accessor :casted_by # Callbacks - define_callbacks :create - define_callbacks :save - define_callbacks :update - define_callbacks :destroy + define_callbacks :create, "result == :halt" + define_callbacks :save, "result == :halt" + define_callbacks :update, "result == :halt" + define_callbacks :destroy, "result == :halt" def initialize(passed_keys={}) apply_defaults # defined in CouchRest::Mixins::Properties @@ -76,17 +76,17 @@ def self.create!(options) # on the document whenever saving occurs. CouchRest uses a pretty # decent time format by default. See Time#to_json def self.timestamps! - class_eval <<-EOS, __FILE__, __LINE__ + class_eval <<-EOS, __FILE__, __LINE__ + 1 property(:updated_at, :read_only => true, :cast_as => 'Time', :auto_validation => false) property(:created_at, :read_only => true, :cast_as => 'Time', :auto_validation => false) - save_callback :before do |object| + set_callback :save, :before do |object| object['updated_at'] = Time.now - object['created_at'] = object['updated_at'] if object.new_document? + object['created_at'] = object['updated_at'] if object.new? end EOS end - + # Name a method that will be called before the document is first saved, # which returns a string to be used for the document's _id. # Because CouchDB enforces a constraint that each id must be unique, @@ -128,17 +128,36 @@ def properties self.class.properties end + # Gets a reference to the actual document in the DB + # Calls up to the next document if there is one, + # Otherwise we're at the top and we return self + def base_doc + return self if base_doc? + @casted_by.base_doc + end + + # Checks if we're the top document + def base_doc? + !@casted_by + end + # Takes a hash as argument, and applies the values by using writer methods # for each key. It doesn't save the document at the end. Raises a NoMethodError if the corresponding methods are # missing. In case of error, no attributes are changed. def update_attributes_without_saving(hash) - hash.each do |k, v| + # remove attributes that cannot be updated, silently ignoring them + # which matches Rails behavior when, for instance, setting created_at. + # make a copy, we don't want to change arguments + attrs = hash.dup + %w[_id _rev created_at updated_at].each {|attr| attrs.delete(attr)} + attrs.each do |k, v| raise NoMethodError, "#{k}= method not available, use property :#{k}" unless self.respond_to?("#{k}=") end - hash.each do |k, v| + attrs.each do |k, v| self.send("#{k}=",v) end end + alias :attributes= :update_attributes_without_saving # Takes a hash as argument, and applies the values by using writer methods # for each key. Raises a NoMethodError if the corresponding methods are @@ -217,7 +236,8 @@ def save_without_callbacks(bulk = false) raise ArgumentError, "a document requires a database to be saved to (The document or the #{self.class} default database were not set)" unless database set_unique_id if new_document? && self.respond_to?(:set_unique_id) result = database.save_doc(self, bulk) - return true + mark_as_saved + true end # Saves the document to the db using save. Raises an exception @@ -242,5 +262,22 @@ def destroy(bulk=false) end end + protected + + # Set document_saved flag on all casted models to true + def mark_as_saved + self.each do |key, prop| + if prop.is_a?(Array) + prop.each do |item| + if item.respond_to?(:document_saved) + item.send(:document_saved=, true) + end + end + elsif prop.respond_to?(:document_saved) + prop.send(:document_saved=, true) + end + end + end + end end diff --git a/lib/couchrest/more/property.rb b/lib/couchrest/more/property.rb index 77e2b90..dccc7dd 100644 --- a/lib/couchrest/more/property.rb +++ b/lib/couchrest/more/property.rb @@ -38,3 +38,22 @@ def parse_options(options) end end + +class CastedArray < Array + attr_accessor :casted_by + + def << obj + obj.casted_by = self.casted_by if obj.respond_to?(:casted_by) + super(obj) + end + + def push(obj) + obj.casted_by = self.casted_by if obj.respond_to?(:casted_by) + super(obj) + end + + def []= index, obj + obj.casted_by = self.casted_by if obj.respond_to?(:casted_by) + super(index, obj) + end +end \ No newline at end of file diff --git a/lib/couchrest/support/class.rb b/lib/couchrest/support/class.rb index ecbab76..5e538ab 100644 --- a/lib/couchrest/support/class.rb +++ b/lib/couchrest/support/class.rb @@ -1,5 +1,5 @@ -# Copyright (c) 2004-2008 David Heinemeier Hansson -# +# Copyright (c) 2006-2009 David Heinemeier Hansson +# # 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 @@ -7,10 +7,10 @@ # 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 @@ -18,79 +18,59 @@ # 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. - -# Allows attributes to be shared within an inheritance hierarchy, but where -# each descendant gets a copy of their parents' attributes, instead of just a -# pointer to the same. This means that the child can add elements to, for -# example, an array without those additions being shared with either their -# parent, siblings, or children, which is unlike the regular class-level -# attributes that are shared across the entire hierarchy. +# +# Extracted From +# http://github.com/rails/rails/commit/971e2438d98326c994ec6d3ef8e37b7e868ed6e2 + +# Extends the class object with class and instance accessors for class attributes, +# just like the native attr* accessors for instance attributes. +# +# class Person +# cattr_accessor :hair_colors +# end +# +# Person.hair_colors = [:brown, :black, :blonde, :red] class Class - # Defines class-level and instance-level attribute reader. - # - # @param *syms Array of attributes to define reader for. - # @return List of attributes that were made into cattr_readers - # - # @api public - # - # @todo Is this inconsistent in that it does not allow you to prevent - # an instance_reader via :instance_reader => false def cattr_reader(*syms) syms.flatten.each do |sym| next if sym.is_a?(Hash) - class_eval(<<-RUBY, __FILE__, __LINE__ + 1) - unless defined? @@#{sym} - @@#{sym} = nil - end - - def self.#{sym} - @@#{sym} - end - - def #{sym} - @@#{sym} - end - RUBY + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + unless defined? @@#{sym} # unless defined? @@hair_colors + @@#{sym} = nil # @@hair_colors = nil + end # end + # + def self.#{sym} # def self.hair_colors + @@#{sym} # @@hair_colors + end # end + # + def #{sym} # def hair_colors + @@#{sym} # @@hair_colors + end # end + EOS end end unless Class.respond_to?(:cattr_reader) - # Defines class-level (and optionally instance-level) attribute writer. - # - # @param Boolean}]> Array of attributes to define writer for. - # @option syms :instance_writer if true, instance-level attribute writer is defined. - # @return List of attributes that were made into cattr_writers - # - # @api public def cattr_writer(*syms) - options = syms.last.is_a?(Hash) ? syms.pop : {} + options = syms.extract_options! syms.flatten.each do |sym| - class_eval(<<-RUBY, __FILE__, __LINE__ + 1) - unless defined? @@#{sym} - @@#{sym} = nil - end - - def self.#{sym}=(obj) - @@#{sym} = obj - end - RUBY - - unless options[:instance_writer] == false - class_eval(<<-RUBY, __FILE__, __LINE__ + 1) - def #{sym}=(obj) - @@#{sym} = obj - end - RUBY - end + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + unless defined? @@#{sym} # unless defined? @@hair_colors + @@#{sym} = nil # @@hair_colors = nil + end # end + # + def self.#{sym}=(obj) # def self.hair_colors=(obj) + @@#{sym} = obj # @@hair_colors = obj + end # end + # + #{" # + def #{sym}=(obj) # def hair_colors=(obj) + @@#{sym} = obj # @@hair_colors = obj + end # end + " unless options[:instance_writer] == false } # # instance writer above is generated unless options[:instance_writer] == false + EOS end end unless Class.respond_to?(:cattr_writer) - # Defines class-level (and optionally instance-level) attribute accessor. - # - # @param *syms Boolean}]> Array of attributes to define accessor for. - # @option syms :instance_writer if true, instance-level attribute writer is defined. - # @return List of attributes that were made into accessors - # - # @api public def cattr_accessor(*syms) cattr_reader(*syms) cattr_writer(*syms) @@ -156,6 +136,8 @@ def self.#{ivar}=(obj) def #{ivar}=(obj) self.class.#{ivar} = obj end RUBY end + + self.send("#{ivar}=", yield) if block_given? end end unless Class.respond_to?(:extlib_inheritable_writer) @@ -168,9 +150,41 @@ def #{ivar}=(obj) self.class.#{ivar} = obj end # @return An Array of attributes turned into inheritable accessors. # # @api public - def extlib_inheritable_accessor(*syms) + def extlib_inheritable_accessor(*syms, &block) extlib_inheritable_reader(*syms) - extlib_inheritable_writer(*syms) + extlib_inheritable_writer(*syms, &block) end unless Class.respond_to?(:extlib_inheritable_accessor) end +class Array + # Extracts options from a set of arguments. Removes and returns the last + # element in the array if it's a hash, otherwise returns a blank hash. + # + # def options(*args) + # args.extract_options! + # end + # + # options(1, 2) # => {} + # options(1, 2, :a => :b) # => {:a=>:b} + def extract_options! + last.is_a?(::Hash) ? pop : {} + end unless Array.new.respond_to?(:extract_options!) + + # Wraps the object in an Array unless it's an Array. Converts the + # object to an Array using #to_ary if it implements that. + def self.wrap(object) + case object + when nil + [] + when self + object + else + if object.respond_to?(:to_ary) + object.to_ary + else + [object] + end + end + end unless Array.respond_to?(:wrap) +end + diff --git a/lib/couchrest/support/rails.rb b/lib/couchrest/support/rails.rb index 19374c0..997c2c6 100644 --- a/lib/couchrest/support/rails.rb +++ b/lib/couchrest/support/rails.rb @@ -1,8 +1,4 @@ # This file contains various hacks for Rails compatibility. -# To use, just require in environment.rb, like so: -# -# require 'couchrest/support/rails' - class Hash # Hack so that CouchRest::Document, which descends from Hash, # doesn't appear to Rails routing as a Hash of options @@ -12,8 +8,10 @@ def self.===(other) end end - CouchRest::Document.class_eval do + # Need this when passing doc to a resourceful route + alias_method :to_param, :id + # Hack so that CouchRest::Document, which descends from Hash, # doesn't appear to Rails routing as a Hash of options def is_a?(o) @@ -23,6 +21,15 @@ def is_a?(o) alias_method :kind_of?, :is_a? end +CouchRest::CastedModel.class_eval do + # The to_param method is needed for rails to generate resourceful routes. + # In your controller, remember that it's actually the id of the document. + def id + return nil if base_doc.nil? + base_doc.id + end + alias_method :to_param, :id +end require Pathname.new(File.dirname(__FILE__)).join('..', 'validation', 'validation_errors') diff --git a/spec/couchrest/core/database_spec.rb b/spec/couchrest/core/database_spec.rb index 98b7947..4bfc1c9 100644 --- a/spec/couchrest/core/database_spec.rb +++ b/spec/couchrest/core/database_spec.rb @@ -253,7 +253,7 @@ describe "PUT attachment from file" do before(:each) do filename = FIXTURE_PATH + '/attachments/couchdb.png' - @file = File.open(filename) + @file = File.open(filename, "rb") end after(:each) do @file.close diff --git a/spec/couchrest/more/casted_extended_doc_spec.rb b/spec/couchrest/more/casted_extended_doc_spec.rb index 6e3e13c..4df60c1 100644 --- a/spec/couchrest/more/casted_extended_doc_spec.rb +++ b/spec/couchrest/more/casted_extended_doc_spec.rb @@ -43,16 +43,14 @@ class Driver < CouchRest::ExtendedDocument @car.driver.should be_nil end - # Note that this isn't casting the attribute, it's just assigning it a value - # (see "should not cast attribute") it "should let you assign the value" do @car.driver = @driver @car.driver.name.should == 'Matt' end - it "should not cast attribute" do + it "should cast attribute" do @car.driver = JSON.parse(JSON.generate(@driver)) - @car.driver.should_not be_instance_of(Driver) + @car.driver.should be_instance_of(Driver) end end diff --git a/spec/couchrest/more/casted_model_spec.rb b/spec/couchrest/more/casted_model_spec.rb index c1de242..04ab737 100644 --- a/spec/couchrest/more/casted_model_spec.rb +++ b/spec/couchrest/more/casted_model_spec.rb @@ -4,6 +4,8 @@ require File.join(FIXTURE_PATH, 'more', 'card') require File.join(FIXTURE_PATH, 'more', 'cat') require File.join(FIXTURE_PATH, 'more', 'person') +require File.join(FIXTURE_PATH, 'more', 'question') +require File.join(FIXTURE_PATH, 'more', 'course') class WithCastedModelMixin < Hash @@ -21,6 +23,26 @@ class DummyModel < CouchRest::ExtendedDocument property :keywords, :cast_as => ["String"] end +class CastedCallbackDoc < CouchRest::ExtendedDocument + use_database TEST_SERVER.default_database + raise "Default DB not set" if TEST_SERVER.default_database.nil? + property :callback_model, :cast_as => 'WithCastedCallBackModel' +end +class WithCastedCallBackModel < Hash + include CouchRest::CastedModel + include CouchRest::Validation + property :name + property :run_before_validate + property :run_after_validate + + before_validate do |object| + object.run_before_validate = true + end + after_validate do |object| + object.run_after_validate = true + end +end + describe CouchRest::CastedModel do describe "A non hash class including CastedModel" do @@ -106,7 +128,40 @@ class NotAHashButWithCastedModelMixin @obj.keywords.should be_an_instance_of(Array) @obj.keywords.first.should == 'couch' end + end + + describe "update attributes without saving" do + before(:each) do + @question = Question.new(:q => "What is your quest?", :a => "To seek the Holy Grail") + end + it "should work for attribute= methods" do + @question.q.should == "What is your quest?" + @question['a'].should == "To seek the Holy Grail" + @question.update_attributes_without_saving(:q => "What is your favorite color?", 'a' => "Blue") + @question['q'].should == "What is your favorite color?" + @question.a.should == "Blue" + end + + it "should also work for attributes= alias" do + @question.respond_to?(:attributes=).should be_true + @question.attributes = {:q => "What is your favorite color?", 'a' => "Blue"} + @question['q'].should == "What is your favorite color?" + @question.a.should == "Blue" + end + + it "should flip out if an attribute= method is missing" do + lambda { + @q.update_attributes_without_saving('foo' => "something", :a => "No green") + }.should raise_error(NoMethodError) + end + it "should not change any attributes if there is an error" do + lambda { + @q.update_attributes_without_saving('foo' => "something", :a => "No green") + }.should raise_error(NoMethodError) + @question.q.should == "What is your quest?" + @question.a.should == "To seek the Holy Grail" + end end describe "saved document with casted models" do @@ -154,6 +209,10 @@ class NotAHashButWithCastedModelMixin toy = CatToy.new :name => "Mouse" @cat.toys.push(toy) @cat.save.should be_true + @cat = Cat.get @cat.id + @cat.toys.class.should == CastedArray + @cat.toys.first.class.should == CatToy + @cat.toys.first.should === toy end it "should fail because name is not present" do @@ -171,7 +230,177 @@ class NotAHashButWithCastedModelMixin cat.masters.push Person.new cat.should be_valid end + end + + describe "calling valid?" do + before :each do + @cat = Cat.new + @toy1 = CatToy.new + @toy2 = CatToy.new + @toy3 = CatToy.new + @cat.favorite_toy = @toy1 + @cat.toys << @toy2 + @cat.toys << @toy3 + end + + describe "on the top document" do + it "should put errors on all invalid casted models" do + @cat.should_not be_valid + @cat.errors.should_not be_empty + @toy1.errors.should_not be_empty + @toy2.errors.should_not be_empty + @toy3.errors.should_not be_empty + end + + it "should not put errors on valid casted models" do + @toy1.name = "Feather" + @toy2.name = "Twine" + @cat.should_not be_valid + @cat.errors.should_not be_empty + @toy1.errors.should be_empty + @toy2.errors.should be_empty + @toy3.errors.should_not be_empty + end + end + + describe "on a casted model property" do + it "should only validate itself" do + @toy1.should_not be_valid + @toy1.errors.should_not be_empty + @cat.errors.should be_empty + @toy2.errors.should be_empty + @toy3.errors.should be_empty + end + end + + describe "on a casted model inside a casted collection" do + it "should only validate itself" do + @toy2.should_not be_valid + @toy2.errors.should_not be_empty + @cat.errors.should be_empty + @toy1.errors.should be_empty + @toy3.errors.should be_empty + end + end + end + + describe "calling new? on a casted model" do + before :each do + reset_test_db! + @cat = Cat.new(:name => 'Sockington') + @favorite_toy = CatToy.new(:name => 'Catnip Ball') + @cat.favorite_toy = @favorite_toy + @cat.toys << CatToy.new(:name => 'Fuzzy Stick') + end + it "should be true on new" do + CatToy.new.should be_new + CatToy.new.new_record?.should be_true + end + + it "should be true after assignment" do + @cat.should be_new + @cat.favorite_toy.should be_new + @cat.toys.first.should be_new + end + + it "should not be true after create or save" do + @cat.create + @cat.save + @cat.favorite_toy.should_not be_new + @cat.toys.first.should_not be_new + end + + it "should not be true after get from the database" do + @cat.save + @cat = Cat.get(@cat.id) + @cat.favorite_toy.should_not be_new + @cat.toys.first.should_not be_new + end + + it "should still be true after a failed create or save" do + @cat.name = nil + @cat.create.should be_false + @cat.save.should be_false + @cat.favorite_toy.should be_new + @cat.toys.first.should be_new + end + end + + describe "calling base_doc from a nested casted model" do + before :each do + @course = Course.new(:title => 'Science 101') + @professor = Person.new(:name => 'Professor Plum') + @cat = Cat.new(:name => 'Scratchy') + @toy1 = CatToy.new + @toy2 = CatToy.new + @course.professor = @professor + @professor.pet = @cat + @cat.favorite_toy = @toy1 + @cat.toys << @toy2 + end + + it "should reference the top document for" do + @course.base_doc.should === @course + @professor.casted_by.should === @course + @professor.base_doc.should === @course + @cat.base_doc.should === @course + @toy1.base_doc.should === @course + @toy2.base_doc.should === @course + end + + it "should call setter on top document" do + @toy1.base_doc.should_not be_nil + @toy1.base_doc.title = 'Tom Foolery' + @course.title.should == 'Tom Foolery' + end + + it "should return nil if not yet casted" do + person = Person.new + person.base_doc.should == nil + end + end + + describe "calling base_doc.save from a nested casted model" do + before :each do + reset_test_db! + @cat = Cat.new(:name => 'Snowball') + @toy = CatToy.new + @cat.favorite_toy = @toy + end + + it "should not save parent document when casted model is invalid" do + @toy.should_not be_valid + @toy.base_doc.save.should be_false + lambda{@toy.base_doc.save!}.should raise_error + end + + it "should save parent document when nested casted model is valid" do + @toy.name = "Mr Squeaks" + @toy.should be_valid + @toy.base_doc.save.should be_true + lambda{@toy.base_doc.save!}.should_not raise_error + end end + describe "callbacks" do + before(:each) do + @doc = CastedCallbackDoc.new + @model = WithCastedCallBackModel.new + @doc.callback_model = @model + end + + describe "validate" do + it "should run before_validate before validating" do + @model.run_before_validate.should be_nil + @model.should be_valid + @model.run_before_validate.should be_true + end + it "should run after_validate after validating" do + @model.run_after_validate.should be_nil + @model.should be_valid + @model.run_after_validate.should be_true + end + end + end end diff --git a/spec/couchrest/more/extended_doc_spec.rb b/spec/couchrest/more/extended_doc_spec.rb index 1b05bc3..d3dfe4b 100644 --- a/spec/couchrest/more/extended_doc_spec.rb +++ b/spec/couchrest/more/extended_doc_spec.rb @@ -1,6 +1,7 @@ require File.expand_path("../../../spec_helper", __FILE__) require File.join(FIXTURE_PATH, 'more', 'article') require File.join(FIXTURE_PATH, 'more', 'course') +require File.join(FIXTURE_PATH, 'more', 'cat') describe "ExtendedDocument" do @@ -17,8 +18,11 @@ class WithDefaultValues < CouchRest::ExtendedDocument end class WithCallBacks < CouchRest::ExtendedDocument + include ::CouchRest::Validation use_database TEST_SERVER.default_database property :name + property :run_before_validate + property :run_after_validate property :run_before_save property :run_after_save property :run_before_create @@ -26,24 +30,56 @@ class WithCallBacks < CouchRest::ExtendedDocument property :run_before_update property :run_after_update - save_callback :before do |object| + before_validate do |object| + object.run_before_validate = true + end + after_validate do |object| + object.run_after_validate = true + end + before_save do |object| object.run_before_save = true end - save_callback :after do |object| + after_save do |object| object.run_after_save = true end - create_callback :before do |object| + before_create do |object| object.run_before_create = true end - create_callback :after do |object| + after_create do |object| object.run_after_create = true end - update_callback :before do |object| + before_update do |object| object.run_before_update = true end - update_callback :after do |object| + after_update do |object| object.run_after_update = true end + + property :run_one + property :run_two + property :run_three + + before_save :run_one_method, :run_two_method do |object| + object.run_three = true + end + def run_one_method + self.run_one = true + end + def run_two_method + self.run_two = true + end + + attr_accessor :run_it + property :conditional_one + property :conditional_two + + before_save :conditional_one_method, :conditional_two_method, :if => proc { self.run_it } + def conditional_one_method + self.conditional_one = true + end + def conditional_two_method + self.conditional_two = true + end end class WithTemplateAndUniqueID < CouchRest::ExtendedDocument @@ -85,15 +121,12 @@ def arg=(value) end describe "a new model" do - it "should be a new_record" do - @obj = Basic.new - @obj.rev.should be_nil - @obj.should be_a_new_record - end - it "should be a new_document" do + it "should be a new document" do @obj = Basic.new @obj.rev.should be_nil - @obj.should be_a_new_document + @obj.should be_new + @obj.should be_new_document + @obj.should be_new_record end end @@ -101,7 +134,7 @@ def arg=(value) it "should instantialize and save a document" do article = Article.create(:title => 'my test') article.title.should == 'my test' - article.should_not be_new_document + article.should_not be_new end it "should trigger the create callbacks" do @@ -125,6 +158,27 @@ def arg=(value) @art.update_attributes_without_saving('date' => Time.now, :title => "super danger") @art['title'].should == "super danger" end + it "should silently ignore _id" do + @art.update_attributes_without_saving('_id' => 'foobar') + @art['_id'].should_not == 'foobar' + end + it "should silently ignore _rev" do + @art.update_attributes_without_saving('_rev' => 'foobar') + @art['_rev'].should_not == 'foobar' + end + it "should silently ignore created_at" do + @art.update_attributes_without_saving('created_at' => 'foobar') + @art['created_at'].should_not == 'foobar' + end + it "should silently ignore updated_at" do + @art.update_attributes_without_saving('updated_at' => 'foobar') + @art['updated_at'].should_not == 'foobar' + end + it "should also work using attributes= alias" do + @art.respond_to?(:attributes=).should be_true + @art.attributes = {'date' => Time.now, :title => "something else"} + @art['title'].should == "something else" + end it "should flip out if an attribute= method is missing" do lambda { @@ -419,7 +473,7 @@ def arg=(value) end it "should be a new document" do - @art.should be_a_new_document + @art.should be_new @art.title.should be_nil end @@ -527,12 +581,46 @@ def arg=(value) @doc = WithCallBacks.new end + + describe "validate" do + it "should run before_validate before validating" do + @doc.run_before_validate.should be_nil + @doc.should be_valid + @doc.run_before_validate.should be_true + end + it "should run after_validate after validating" do + @doc.run_after_validate.should be_nil + @doc.should be_valid + @doc.run_after_validate.should be_true + end + end describe "save" do it "should run the after filter after saving" do @doc.run_after_save.should be_nil @doc.save.should be_true @doc.run_after_save.should be_true end + it "should run the grouped callbacks before saving" do + @doc.run_one.should be_nil + @doc.run_two.should be_nil + @doc.run_three.should be_nil + @doc.save.should be_true + @doc.run_one.should be_true + @doc.run_two.should be_true + @doc.run_three.should be_true + end + it "should not run conditional callbacks" do + @doc.run_it = false + @doc.save.should be_true + @doc.conditional_one.should be_nil + @doc.conditional_two.should be_nil + end + it "should run conditional callbacks" do + @doc.run_it = true + @doc.save.should be_true + @doc.conditional_one.should be_true + @doc.conditional_two.should be_true + end end describe "create" do it "should run the before save filter when creating" do @@ -585,4 +673,49 @@ def arg=(value) @doc.other_arg.should == "foo-foo" end end + + describe "recursive validation on an extended document" do + before :each do + reset_test_db! + @cat = Cat.new(:name => 'Sockington') + end + + it "should not save if a nested casted model is invalid" do + @cat.favorite_toy = CatToy.new + @cat.should_not be_valid + @cat.save.should be_false + lambda{@cat.save!}.should raise_error + end + + it "should save when nested casted model is valid" do + @cat.favorite_toy = CatToy.new(:name => 'Squeaky') + @cat.should be_valid + @cat.save.should be_true + lambda{@cat.save!}.should_not raise_error + end + + it "should not save when nested collection contains an invalid casted model" do + @cat.toys = [CatToy.new(:name => 'Feather'), CatToy.new] + @cat.should_not be_valid + @cat.save.should be_false + lambda{@cat.save!}.should raise_error + end + + it "should save when nested collection contains valid casted models" do + @cat.toys = [CatToy.new(:name => 'feather'), CatToy.new(:name => 'ball-o-twine')] + @cat.should be_valid + @cat.save.should be_true + lambda{@cat.save!}.should_not raise_error + end + + it "should not fail if the nested casted model doesn't have validation" do + Cat.property :trainer, :cast_as => 'Person' + Cat.validates_present :name + cat = Cat.new(:name => 'Mr Bigglesworth') + cat.trainer = Person.new + cat.trainer.validatable?.should be_false + cat.should be_valid + cat.save.should be_true + end + end end diff --git a/spec/couchrest/more/property_spec.rb b/spec/couchrest/more/property_spec.rb index 126d86f..73c632b 100644 --- a/spec/couchrest/more/property_spec.rb +++ b/spec/couchrest/more/property_spec.rb @@ -4,6 +4,7 @@ require File.join(FIXTURE_PATH, 'more', 'invoice') require File.join(FIXTURE_PATH, 'more', 'service') require File.join(FIXTURE_PATH, 'more', 'event') +require File.join(FIXTURE_PATH, 'more', 'cat') describe "ExtendedDocument properties" do @@ -94,7 +95,7 @@ @invoice.location = nil @invoice.should_not be_valid @invoice.save.should be_false - @invoice.should be_new_document + @invoice.should be_new end end @@ -191,5 +192,69 @@ class RootBeerFloat < CouchRest::ExtendedDocument end end +end + +describe "a newly created casted model" do + before(:each) do + reset_test_db! + @cat = Cat.new(:name => 'Toonces') + @squeaky_mouse = CatToy.new(:name => 'Squeaky') + end + + describe "assigned assigned to a casted property" do + it "should have casted_by set to its parent" do + @squeaky_mouse.casted_by.should be_nil + @cat.favorite_toy = @squeaky_mouse + @squeaky_mouse.casted_by.should === @cat + end + end + + describe "appended to a casted collection" do + it "should have casted_by set to its parent" do + @squeaky_mouse.casted_by.should be_nil + @cat.toys << @squeaky_mouse + @squeaky_mouse.casted_by.should === @cat + @cat.save + @cat.toys.first.casted_by.should === @cat + end + end + describe "list assigned to a casted collection" do + it "should have casted_by set on all elements" do + toy1 = CatToy.new(:name => 'Feather') + toy2 = CatToy.new(:name => 'Mouse') + @cat.toys = [toy1, toy2] + toy1.casted_by.should === @cat + toy2.casted_by.should === @cat + @cat.save + @cat = Cat.get(@cat.id) + @cat.toys[0].casted_by.should === @cat + @cat.toys[1].casted_by.should === @cat + end + end end + +describe "a casted model retrieved from the database" do + before(:each) do + reset_test_db! + @cat = Cat.new(:name => 'Stimpy') + @cat.favorite_toy = CatToy.new(:name => 'Stinky') + @cat.toys << CatToy.new(:name => 'Feather') + @cat.toys << CatToy.new(:name => 'Mouse') + @cat.save + @cat = Cat.get(@cat.id) + end + + describe "as a casted property" do + it "should already be casted_by its parent" do + @cat.favorite_toy.casted_by.should === @cat + end + end + + describe "from a casted collection" do + it "should already be casted_by its parent" do + @cat.toys[0].casted_by.should === @cat + @cat.toys[1].casted_by.should === @cat + end + end +end \ No newline at end of file diff --git a/spec/fixtures/more/article.rb b/spec/fixtures/more/article.rb index e0a6393..6f4bb7a 100644 --- a/spec/fixtures/more/article.rb +++ b/spec/fixtures/more/article.rb @@ -26,9 +26,9 @@ class Article < CouchRest::ExtendedDocument timestamps! - save_callback :before, :generate_slug_from_title + before_save :generate_slug_from_title def generate_slug_from_title - self['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') if new_document? + self['slug'] = title.downcase.gsub(/[^a-z0-9]/,'-').squeeze('-').gsub(/^\-|\-$/,'') if new? end end \ No newline at end of file diff --git a/spec/fixtures/more/cat.rb b/spec/fixtures/more/cat.rb index 54abad5..a3cb054 100644 --- a/spec/fixtures/more/cat.rb +++ b/spec/fixtures/more/cat.rb @@ -6,6 +6,7 @@ class Cat < CouchRest::ExtendedDocument property :name property :toys, :cast_as => ['CatToy'], :default => [] + property :favorite_toy, :cast_as => 'CatToy' end class CatToy < Hash diff --git a/spec/fixtures/more/person.rb b/spec/fixtures/more/person.rb index ddc1bfd..de9e72c 100644 --- a/spec/fixtures/more/person.rb +++ b/spec/fixtures/more/person.rb @@ -1,6 +1,7 @@ class Person < Hash include ::CouchRest::CastedModel property :name + property :pet, :cast_as => 'Cat' def last_name name.last