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