now using ActiveModel::Dirty. only writes to database if model.changed?

This commit is contained in:
Andrew Williams 2011-03-01 01:30:41 +10:30
parent 53b052f631
commit 4dbf694e51
14 changed files with 342 additions and 18 deletions

2
.gitignore vendored
View file

@ -6,4 +6,4 @@ pkg
.bundle
couchdb.std*
*.*~
spec.out

View file

@ -16,6 +16,7 @@ module CouchRest
include CouchRest::Model::PropertyProtection
include CouchRest::Model::Associations
include CouchRest::Model::Validations
include CouchRest::Model::Dirty
def self.subclasses
@subclasses ||= []

View file

@ -5,6 +5,7 @@
module CouchRest::Model
class CastedArray < Array
include CouchRest::Model::Dirty
attr_accessor :casted_by
attr_accessor :property
@ -14,15 +15,34 @@ module CouchRest::Model
end
def << obj
couchrest_parent_will_change!
super(instantiate_and_cast(obj))
end
def push(obj)
couchrest_parent_will_change!
super(instantiate_and_cast(obj))
end
def pop
couchrest_parent_will_change!
super
end
def shift
couchrest_parent_will_change!
super
end
def unshift(obj)
couchrest_parent_will_change!
super(obj)
end
def []= index, obj
super(index, instantiate_and_cast(obj))
value = instantiate_and_cast(obj)
couchrest_parent_will_change! if value != self[index]
super(index, value)
end
protected

View file

@ -0,0 +1,20 @@
#
# Wrapper around Hash so that the casted_by attribute is set.
module CouchRest::Model
class CastedHash < Hash
include CouchRest::Model::Dirty
attr_accessor :casted_by
def []= index, obj
couchrest_parent_will_change! if obj != self[index]
super(index, obj)
end
# needed for dirty
def attributes
self
end
end
end

View file

@ -10,6 +10,7 @@ module CouchRest::Model
include CouchRest::Model::PropertyProtection
include CouchRest::Model::Associations
include CouchRest::Model::Validations
include CouchRest::Model::Dirty
attr_accessor :casted_by
end
@ -20,6 +21,7 @@ module CouchRest::Model
end
def []= key, value
couchrest_attribute_will_change!(key) unless self[key] == value
super(key.to_s, value)
end
@ -64,5 +66,6 @@ module CouchRest::Model
end
end
alias :attributes= :update_attributes_without_saving
end
end

View file

@ -0,0 +1,41 @@
# encoding: utf-8
I18n.load_path << File.join(
File.dirname(__FILE__), "validations", "locale", "en.yml"
)
module CouchRest
module Model
# This applies to both Model::Base and Model::CastedModel
module Dirty
extend ActiveSupport::Concern
included do
include ActiveModel::Dirty
end
def couchrest_attribute_will_change!(attr)
return if attr.nil?
self.send("#{attr}_will_change!")
if pkey = casted_by_attribute
@casted_by.couchrest_attribute_will_change!(pkey)
end
end
def couchrest_parent_will_change!
@casted_by.couchrest_attribute_will_change!(casted_by_attribute) if @casted_by
end
private
# return the attribute name this object is referenced by in the parent
def casted_by_attribute
return nil unless @casted_by
attr = @casted_by.attributes
attr.keys.detect { |k| attr[k] == self }
end
end
end
end

View file

@ -1,6 +1,12 @@
module CouchRest
module Model
module ExtendedAttachments
extend ActiveSupport::Concern
include ActiveModel::Dirty
included do
# for _attachments_will_change!
define_attribute_methods [:_attachments]
end
# Add a file attachment to the current document. Expects
# :file and :name to be included in the arguments.
@ -35,6 +41,7 @@ module CouchRest
# deletes a file attachment from the current doc
def delete_attachment(attachment_name)
return unless attachments
_attachments_will_change! if attachments.include?(attachment_name)
attachments.delete attachment_name
end
@ -66,6 +73,8 @@ module CouchRest
def set_attachment_attr(args)
content_type = args[:content_type] ? args[:content_type] : get_mime_type(args[:file].path)
content_type ||= (get_mime_type(args[:name]) || 'text/plain')
_attachments_will_change!
attachments[args[:name]] = {
'content_type' => content_type,
'data' => args[:file].read

View file

@ -12,7 +12,9 @@ module CouchRest
_run_save_callbacks do
set_unique_id if new? && self.respond_to?(:set_unique_id)
result = database.save_doc(self)
(result["ok"] == true) ? self : false
ret = (result["ok"] == true) ? self : false
@changed_attributes.clear if ret && @changed_attributes
ret
end
end
end
@ -28,10 +30,13 @@ module CouchRest
def update(options = {})
raise "Calling #{self.class.name}#update on document that has not been created!" if self.new?
return false unless perform_validations(options)
return true unless self.changed?
_run_update_callbacks do
_run_save_callbacks do
result = database.save_doc(self)
result["ok"] == true
ret = result["ok"] == true
@changed_attributes.clear if ret && @changed_attributes
ret
end
end
end
@ -140,12 +145,18 @@ module CouchRest
# should use the class name as part of the unique id.
def unique_id method = nil, &block
if method
define_method :get_unique_id do
self.send(method)
end
define_method :set_unique_id do
self['_id'] ||= self.send(method)
self['_id'] ||= get_unique_id
end
elsif block
define_method :get_unique_id do
block.call(self)
end
define_method :set_unique_id do
uniqid = block.call(self)
uniqid = get_unique_id
raise ArgumentError, "unique_id block must not return nil" if uniqid.nil?
self['_id'] ||= uniqid
end

View file

@ -3,6 +3,7 @@ module CouchRest
module Model
module Properties
extend ActiveSupport::Concern
include ActiveModel::Dirty
included do
extlib_inheritable_accessor(:properties) unless self.respond_to?(:properties)
@ -38,11 +39,33 @@ module CouchRest
# Store a casted value in the current instance of an attribute defined
# with a property.
# TODO: mixin dirty functionality into value (?)
def write_attribute(property, value)
prop = find_property!(property)
self[prop.to_s] = prop.is_a?(String) ? value : prop.cast(self, value)
end
# write property, update dirty status
def write_attribute_dirty(property, value)
prop = find_property!(property)
value = prop.is_a?(String) ? value : prop.cast(self, value)
self.send("#{prop}_will_change!") unless self[prop.to_s] == value
write_attribute(property, value)
end
def []=(key,value)
old_id = get_unique_id if self.respond_to?(:get_unique_id)
super(key, value)
if self.respond_to?(:get_unique_id)
# if we have set an attribute that results in the _id changing (unique_id),
# force changed? to return true so that the record can be saved
new_id = get_unique_id
changed_attributes["_id"] = new_id if old_id != new_id
end
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.
@ -50,12 +73,23 @@ module CouchRest
# Remove any protected and update all the rest. Any attributes
# which do not have a property will simply be ignored.
attrs = remove_protected_attributes(hash)
directly_set_attributes(attrs)
directly_set_attributes(attrs, :dirty => true)
end
alias :attributes= :update_attributes_without_saving
# needed for Dirty
def attributes
ret = {}
self.class.properties.each do |property|
ret[property.name] = read_attribute(property)
end
ret
end
def find_property(property)
property.is_a?(Property) ? property : self.class.properties.detect {|p| p.to_s == property.to_s}
end
private
# The following methods should be accessable by the Model::Base Class, but not by anything else!
def apply_all_property_defaults
return if self.respond_to?(:new?) && (new? == false)
@ -76,17 +110,26 @@ module CouchRest
end
def find_property!(property)
prop = property.is_a?(Property) ? property : self.class.properties.detect {|p| p.to_s == property.to_s}
prop = find_property(property)
raise ArgumentError, "Missing property definition for #{property.to_s}" if prop.nil?
prop
end
# Set all the attributes and return a hash with the attributes
# that have not been accepted.
def directly_set_attributes(hash)
def directly_set_attributes(hash, options = {})
hash.reject do |attribute_name, attribute_value|
if self.respond_to?("#{attribute_name}=")
self.send("#{attribute_name}=", attribute_value)
if find_property(attribute_name)
if options[:dirty]
self.write_attribute_dirty(attribute_name, attribute_value)
else
# set attribute without updating dirty status
self.write_attribute(attribute_name, attribute_value)
end
else
self.send("#{attribute_name}=", attribute_value)
end
true
elsif mass_assign_any_attribute # config option
self[attribute_name] = attribute_value
@ -139,8 +182,8 @@ module CouchRest
property(:created_at, Time, :read_only => true, :protected => true, :auto_validation => false)
set_callback :save, :before do |object|
write_attribute('updated_at', Time.now)
write_attribute('created_at', Time.now) if object.new?
write_attribute_dirty('updated_at', Time.now)
write_attribute_dirty('created_at', Time.now) if object.new?
end
EOS
end
@ -203,7 +246,7 @@ module CouchRest
property_name = property.name
class_eval <<-EOS
def #{property_name}=(value)
write_attribute('#{property_name}', value)
write_attribute_dirty('#{property_name}', value)
end
EOS

View file

@ -40,6 +40,10 @@ module CouchRest::Model
# allow casted_by calls to be passed up chain by wrapping in CastedArray
value = type_class != String ? CastedArray.new(arr, self) : arr
value.casted_by = parent if value.respond_to?(:casted_by)
elsif (type == Object || type == Hash) && (value.class == Hash)
# allow casted_by calls to be passed up chain by wrapping in CastedHash
value = CouchRest::Model::CastedHash[value]
value.casted_by = parent
elsif !value.nil?
value = cast_value(parent, value)
end

View file

@ -8,6 +8,7 @@ require "active_model/serialization"
require "active_model/translation"
require "active_model/validator"
require "active_model/validations"
require "active_model/dirty"
require 'active_support/core_ext'
require 'active_support/json'
@ -28,8 +29,11 @@ require "couchrest/model/persistence"
require "couchrest/model/typecast"
require "couchrest/model/property"
require "couchrest/model/property_protection"
require "couchrest/model/casted_array"
require "couchrest/model/properties"
require "couchrest/model/dirty"
require "couchrest/model/casted_array"
require "couchrest/model/casted_hash"
require "couchrest/model/casted_model"
require "couchrest/model/validations"
require "couchrest/model/callbacks"
require "couchrest/model/document_queries"

View file

@ -372,6 +372,7 @@ describe "Model Base" do
foundart.created_at.should == foundart.updated_at
end
it "should set the time on update" do
@art.title = "new title" # only saved if @art.changed? == true
@art.save
@art.created_at.should < @art.updated_at
end

View file

@ -0,0 +1,167 @@
require File.expand_path("../../spec_helper", __FILE__)
require File.join(FIXTURE_PATH, 'more', 'cat')
require File.join(FIXTURE_PATH, 'more', 'article')
require File.join(FIXTURE_PATH, 'more', 'course')
require File.join(FIXTURE_PATH, 'more', 'card')
require File.join(FIXTURE_PATH, 'base')
class WithCastedModelMixin < Hash
include CouchRest::Model::CastedModel
property :name
property :details, Object, :default => {}
property :casted_attribute, WithCastedModelMixin
end
class DummyModel < CouchRest::Model::Base
use_database TEST_SERVER.default_database
raise "Default DB not set" if TEST_SERVER.default_database.nil?
property :casted_attribute, WithCastedModelMixin
property :details, Object, :default => {}
property :keywords, [String]
property :sub_models do |child|
child.property :title
end
end
describe "Dirty" do
describe "changes" do
it "should return changes on an attribute" do
@card = Card.new(:first_name => "matt")
@card.first_name = "andrew"
@card.changes.should == { "first_name" => ["matt", "andrew"] }
end
end
describe "save" do
it "should not save unchanged records" do
card_id = Card.create!(:first_name => "matt").id
@card = Card.find(card_id)
@card.database.should_not_receive(:save_doc)
@card.save
end
it "should save changed records" do
card_id = Card.create!(:first_name => "matt").id
@card = Card.find(card_id)
@card.first_name = "andrew"
@card.database.should_receive(:save_doc).and_return({"ok" => true})
@card.save
end
end
describe "changed?" do
# match activerecord behaviour
it "should report no changes on a new object with no attributes set" do
@card = Card.new
@card.changed?.should be_false
end
=begin
# match activerecord behaviour
# not currently working - not too important
it "should report changes on a new object with attributes set" do
@card = Card.new(:first_name => "matt")
@card.changed?.should be_true
end
=end
it "should report no changes on objects fetched from the database" do
card_id = Card.create!(:first_name => "matt").id
@card = Card.find(card_id)
@card.changed?.should be_false
end
it "should report changes if the record is modified" do
@card = Card.new
@card.first_name = "andrew"
@card.changed?.should be_true
@card.first_name_changed?.should be_true
end
it "should report no changes for unmodified records" do
card_id = Card.create!(:first_name => "matt").id
@card = Card.find(card_id)
@card.first_name = "matt"
@card.changed?.should be_false
@card.first_name_changed?.should be_false
end
it "should report no changes after a new record has been saved" do
@card = Card.new(:first_name => "matt")
@card.save!
@card.changed?.should be_false
end
it "should report no changes after a record has been saved" do
card_id = Card.create!(:first_name => "matt").id
@card = Card.find(card_id)
@card.first_name = "andrew"
@card.save!
@card.changed?.should be_false
end
# test changing list properties
it "should report changes if a list property is modified" do
cat_id = Cat.create!(:name => "Felix", :toys => [{:name => "Mouse"}]).id
@cat = Cat.find(cat_id)
@cat.toys = [{:name => "Feather"}]
@cat.changed?.should be_true
end
it "should report no changes if a list property is unmodified" do
cat_id = Cat.create!(:name => "Felix", :toys => [{:name => "Mouse"}]).id
@cat = Cat.find(cat_id)
@cat.toys = [{:name => "Mouse"}] # same as original list
@cat.changed?.should be_false
end
# attachments
it "should report changes if an attachment is added" do
cat_id = Cat.create!(:name => "Felix", :toys => [{:name => "Mouse"}]).id
@file = File.open(FIXTURE_PATH + '/attachments/test.html')
@cat = Cat.find(cat_id)
@cat.create_attachment(:file => @file, :name => "my_attachment")
@cat.changed?.should be_true
end
it "should report changes if an attachment is deleted" do
@cat = Cat.create!(:name => "Felix", :toys => [{:name => "Mouse"}])
@file = File.open(FIXTURE_PATH + '/attachments/test.html')
@attachment_name = "my_attachment"
@cat.create_attachment(:file => @file, :name => @attachment_name)
@cat.save
@cat = Cat.find(@cat.id)
@cat.delete_attachment(@attachment_name)
@cat.changed?.should be_true
end
# casted models
it "should report changes to casted models" do
@cat = Cat.create!(:name => "Felix", :favorite_toy => { :name => "Mouse" })
@cat = Cat.find(@cat.id)
@cat.favorite_toy['name'] = 'Feather'
@cat.changed?.should be_true
end
it "should report changes to hashes" do
@obj = DummyModel.create!
@obj = DummyModel.get(@obj.id)
deets = @obj.details
deets['color'] = 'orange'
@obj.changed?.should be_true
end
end
end

View file

@ -131,7 +131,7 @@ class WithUniqueValidationView < CouchRest::Model::Base
attr_accessor :code
unique_id :code
def code
self["_id"] ||= @code
@code
end
property :title