ricordami 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. data/CHANGELOG.md +13 -0
  2. data/ISSUES.md +0 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +454 -0
  5. data/TODO.md +21 -0
  6. data/examples/calls.rb +64 -0
  7. data/examples/singers.rb +42 -0
  8. data/lib/ricordami/attribute.rb +52 -0
  9. data/lib/ricordami/can_be_queried.rb +133 -0
  10. data/lib/ricordami/can_be_validated.rb +27 -0
  11. data/lib/ricordami/can_have_relationships.rb +152 -0
  12. data/lib/ricordami/configuration.rb +39 -0
  13. data/lib/ricordami/connection.rb +22 -0
  14. data/lib/ricordami/exceptions.rb +14 -0
  15. data/lib/ricordami/has_attributes.rb +159 -0
  16. data/lib/ricordami/has_indices.rb +105 -0
  17. data/lib/ricordami/is_lockable.rb +52 -0
  18. data/lib/ricordami/is_persisted.rb +123 -0
  19. data/lib/ricordami/is_retrievable.rb +35 -0
  20. data/lib/ricordami/key_namer.rb +63 -0
  21. data/lib/ricordami/model.rb +29 -0
  22. data/lib/ricordami/query.rb +68 -0
  23. data/lib/ricordami/relationship.rb +40 -0
  24. data/lib/ricordami/unique_index.rb +59 -0
  25. data/lib/ricordami/unique_validator.rb +21 -0
  26. data/lib/ricordami/value_index.rb +26 -0
  27. data/lib/ricordami/version.rb +3 -0
  28. data/lib/ricordami.rb +26 -0
  29. data/spec/acceptance/manage_relationships_spec.rb +42 -0
  30. data/spec/acceptance/model_with_validation_spec.rb +78 -0
  31. data/spec/acceptance/query_model_spec.rb +93 -0
  32. data/spec/acceptance_helper.rb +2 -0
  33. data/spec/ricordami/attribute_spec.rb +113 -0
  34. data/spec/ricordami/can_be_queried_spec.rb +254 -0
  35. data/spec/ricordami/can_be_validated_spec.rb +115 -0
  36. data/spec/ricordami/can_have_relationships_spec.rb +255 -0
  37. data/spec/ricordami/configuration_spec.rb +45 -0
  38. data/spec/ricordami/connection_spec.rb +25 -0
  39. data/spec/ricordami/exceptions_spec.rb +43 -0
  40. data/spec/ricordami/has_attributes_spec.rb +266 -0
  41. data/spec/ricordami/has_indices_spec.rb +73 -0
  42. data/spec/ricordami/is_lockable_spec.rb +45 -0
  43. data/spec/ricordami/is_persisted_spec.rb +186 -0
  44. data/spec/ricordami/is_retrievable_spec.rb +55 -0
  45. data/spec/ricordami/key_namer_spec.rb +56 -0
  46. data/spec/ricordami/model_spec.rb +65 -0
  47. data/spec/ricordami/query_spec.rb +156 -0
  48. data/spec/ricordami/relationship_spec.rb +123 -0
  49. data/spec/ricordami/unique_index_spec.rb +87 -0
  50. data/spec/ricordami/unique_validator_spec.rb +41 -0
  51. data/spec/ricordami/value_index_spec.rb +40 -0
  52. data/spec/spec_helper.rb +29 -0
  53. data/spec/support/constants.rb +43 -0
  54. data/spec/support/db_manager.rb +18 -0
  55. data/test/bin/data_loader.rb +107 -0
  56. data/test/data/domains.txt +462 -0
  57. data/test/data/first_names.txt +1220 -0
  58. data/test/data/last_names.txt +1028 -0
  59. data/test/data/people_100_000.csv.bz2 +0 -0
  60. data/test/data/people_10_000.csv.bz2 +0 -0
  61. data/test/data/people_1_000_000.csv.bz2 +0 -0
  62. metadata +258 -0
@@ -0,0 +1,123 @@
1
+ require "ricordami/has_attributes"
2
+
3
+ module Ricordami
4
+ module IsPersisted
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ attr_reader :save_queue, :delete_queue
9
+
10
+ def create(*args)
11
+ new(*args).tap do |instance|
12
+ instance.save
13
+ end
14
+ end
15
+
16
+ def load_attributes_for(id)
17
+ key_name = attributes_key_name_for(id)
18
+ redis.hgetall(key_name)
19
+ end
20
+
21
+ [[:saving, :save_queue], [:deleting, :delete_queue]].each do |action, queue|
22
+ var_name = :"@#{queue}"
23
+ define_method(:"queue_#{action}_operations") do |&block|
24
+ raise ArgumentError.new("missing block") unless block
25
+ raise ArgumentError.new("expecting block with 2 arguments") unless block.arity == 2
26
+ instance_variable_set(var_name, []) unless instance_variable_get(var_name)
27
+ instance_variable_get(var_name) << block
28
+ end
29
+ end
30
+
31
+ def redis
32
+ @redis ||= Ricordami.driver
33
+ end
34
+ end
35
+
36
+ module InstanceMethods
37
+ def initialize(*args)
38
+ super(*args)
39
+ @persisted = false unless instance_variable_defined?(:@persisted)
40
+ @deleted = false
41
+ end
42
+
43
+ def persisted?
44
+ @persisted
45
+ end
46
+
47
+ def new_record?
48
+ !@persisted
49
+ end
50
+
51
+ def deleted?
52
+ @deleted
53
+ end
54
+
55
+ def save(opts = {})
56
+ raise ModelHasBeenDeleted.new("can't save a deleted model") if deleted?
57
+ set_initial_attribute_values if new_record?
58
+ redis.tap do |driver|
59
+ session = {}
60
+ driver.multi
61
+ driver.hmset(attributes_key_name, *attributes.to_a.flatten)
62
+ self.class.save_queue.each { |block| block.call(self, session) } if self.class.save_queue
63
+ driver.exec
64
+ end
65
+ @persisted = true
66
+ attributes_synced_with_db!
67
+ rescue Exception => ex
68
+ raise ex if ex.is_a?(ModelHasBeenDeleted)
69
+ false
70
+ end
71
+
72
+ def reload
73
+ attrs = self.class.send(:load_attributes_for, id)
74
+ update_mem_attributes(attrs) unless attrs.empty?
75
+ attributes_synced_with_db!
76
+ self
77
+ end
78
+
79
+ def update_attributes(attrs)
80
+ raise ModelHasBeenDeleted.new("can't update the attributes of a deleted model") if deleted?
81
+ update_mem_attributes!(attrs) unless attrs.empty?
82
+ save
83
+ end
84
+
85
+ def delete
86
+ raise ModelHasBeenDeleted.new("can't delete a model already deleted") if deleted?
87
+ db_commands, models = [], []
88
+ # TODO: use watch (Redis 2.2) and re-run prepare + execute if change
89
+ session = Struct.new(:models, :commands).new(models, db_commands)
90
+ prepare_delete(session)
91
+ execute_delete(db_commands)
92
+ models.each { |model| model.mark_as_deleted }
93
+ end
94
+
95
+ def prepare_delete(session)
96
+ session.models << self
97
+ session.commands << [:del, [attributes_key_name]]
98
+ self.class.delete_queue.reverse.each { |block| block.call(self, session) } if self.class.delete_queue
99
+ end
100
+
101
+ def execute_delete(db_commands)
102
+ redis.tap do |driver|
103
+ driver.multi
104
+ db_commands.each do |message, args|
105
+ driver.send(message, *args)
106
+ end
107
+ driver.exec
108
+ end
109
+ end
110
+
111
+ def mark_as_deleted
112
+ attributes_synced_with_db!
113
+ @deleted = true
114
+ freeze
115
+ true
116
+ end
117
+
118
+ def redis
119
+ self.class.redis
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,35 @@
1
+ require "ricordami/is_persisted"
2
+ require "ricordami/has_indices"
3
+
4
+ module Ricordami
5
+ module IsRetrievable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ index :unique => :id
10
+ end
11
+
12
+ module ClassMethods
13
+ def get(id)
14
+ attributes = load_attributes_for(id)
15
+ raise NotFound.new("id = #{id}") if attributes.empty?
16
+ new(attributes).tap do |instance|
17
+ instance.instance_eval do
18
+ @persisted = true
19
+ attributes_synced_with_db!
20
+ end
21
+ end
22
+ end
23
+ alias :[] :get
24
+
25
+ def all(expressions = nil)
26
+ ids = indices[:id].all
27
+ ids.map { |id| get(id) }
28
+ end
29
+
30
+ def count
31
+ indices[:id].count
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,63 @@
1
+ require "base64"
2
+
3
+ module Ricordami
4
+ module KeyNamer
5
+ extend self
6
+
7
+ def sequence(model, opts = {})
8
+ "#{model}:seq:#{opts[:type]}"
9
+ end
10
+
11
+ def attributes(model, opts = {})
12
+ "#{model}:att:#{opts[:id]}"
13
+ end
14
+
15
+ def unique_index(model, opts = {})
16
+ "#{model}:udx:#{opts[:name]}"
17
+ end
18
+
19
+ def hash_ref(model, opts = {})
20
+ fields = opts[:fields].join("_") + "_to_id"
21
+ "#{model}:hsh:#{fields}"
22
+ end
23
+
24
+ def index(model, opts = {})
25
+ value = encode(opts[:value])
26
+ "#{model}:idx:#{opts[:field]}:#{value}"
27
+ end
28
+
29
+ def volatile_set(model, opts = {})
30
+ info = opts[:info].dup
31
+ op = info.shift
32
+ if info.empty?
33
+ info = [op]
34
+ else
35
+ info = ["#{op}(#{info.join(",")})"]
36
+ end
37
+ unless opts[:key].nil?
38
+ key = opts[:key].sub("~:#{model}:set:", "")
39
+ info.unshift(key)
40
+ end
41
+ "~:#{model}:set:#{info.join(":")}"
42
+ end
43
+
44
+ def temporary(model)
45
+ lock_id = model.redis.incr("#{model}:seq:lock")
46
+ "#{model}:val:_tmp:#{lock_id}"
47
+ end
48
+
49
+ def lock(model, opts = {})
50
+ "#{model}:val:#{opts[:id]}:_lock"
51
+ end
52
+
53
+ def sort(model, opts = {})
54
+ "#{model}:att:*->#{opts[:sort_by]}"
55
+ end
56
+
57
+ private
58
+
59
+ def encode(value)
60
+ Base64.encode64(value.to_s).gsub("\n", "")
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ module Ricordami
2
+ module Model
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ extend ActiveModel::Naming
7
+ include ActiveModel::Conversion
8
+ include ActiveModel::AttributeMethods
9
+ include IsLockable
10
+ include HasAttributes
11
+ include HasIndices
12
+ include IsPersisted
13
+ include IsRetrievable
14
+ end
15
+
16
+ module ClassMethods
17
+ def model_can(*features)
18
+ features.each do |feature|
19
+ require File.expand_path("../can_#{feature}", __FILE__)
20
+ feature_module = Ricordami.const_get(:"Can#{feature.to_s.camelize}")
21
+ include feature_module
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ include Configuration
28
+ include Connection
29
+ end
@@ -0,0 +1,68 @@
1
+ module Ricordami
2
+ class Query
3
+ attr_reader :expressions, :runner, :builder, :sort_by, :sort_dir
4
+
5
+ def initialize(runner, builder = nil)
6
+ @expressions = []
7
+ @runner = runner
8
+ @builder = builder || runner
9
+ end
10
+
11
+ [:and, :not, :any].each do |op|
12
+ define_method(op) do |*args|
13
+ options = args.first || {}
14
+ @expressions << [op, options.dup]
15
+ self
16
+ end
17
+ end
18
+ alias :where :and
19
+
20
+ [:all, :paginate, :first, :last, :rand].each do |cmd|
21
+ define_method(cmd) do |*args|
22
+ return runner unless runner.respond_to?(cmd)
23
+ options = args.first || {}
24
+ options[:expressions] = expressions
25
+ options[:sort_by] = @sort_by unless @sort_by.nil?
26
+ options[:order] = order_for(@sort_dir) unless @sort_dir.nil?
27
+ runner.send(cmd, options)
28
+ end
29
+ end
30
+
31
+ def sort(attribute, dir = :asc_alpha)
32
+ unless [:asc_alpha, :asc_num, :desc_alpha, :desc_num].include?(dir)
33
+ raise ArgumentError.new("sorting direction #{dir.inspect} is invalid")
34
+ end
35
+ @sort_by, @sort_dir = attribute, dir
36
+ self
37
+ end
38
+
39
+ def build(attributes = {})
40
+ initial_values = {}
41
+ expressions.each do |operation, filters|
42
+ next unless operation == :and
43
+ initial_values.merge!(filters)
44
+ end
45
+ obj = builder.new(initial_values.merge(attributes))
46
+ end
47
+
48
+ def create(attributes = {})
49
+ build(attributes).tap { |obj| obj.save }
50
+ end
51
+
52
+ private
53
+
54
+ def order_for(dir)
55
+ case dir
56
+ when nil then nil
57
+ when :asc_alpha then "ALPHA ASC"
58
+ when :asc_num then "ASC"
59
+ when :desc_alpha then "ALPHA DESC"
60
+ else "DESC"
61
+ end
62
+ end
63
+
64
+ def method_missing(meth, *args, &blk)
65
+ all.send(meth, *args, &blk)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,40 @@
1
+ module Ricordami
2
+ class Relationship
3
+ SUPPORTED_TYPES = [:references_many, :references_one, :referenced_in]
4
+ MANDATORY_ARGS = [:other, :self]
5
+
6
+ attr_reader :type, :name, :object_kind, :dependent, :self_kind, :alias
7
+
8
+ def initialize(type, options = {})
9
+ options.assert_valid_keys(:other, :as, :self, :alias, :dependent)
10
+ raise TypeNotSupported.new(type.to_s) unless SUPPORTED_TYPES.include?(type)
11
+ missing = find_missing_args(options)
12
+ raise MissingMandatoryArgs.new(missing.map(&:to_s).join(", ")) unless missing.empty?
13
+ if options[:dependent] && ![:delete, :nullify].include?(options[:dependent])
14
+ raise OptionValueInvalid.new(options[:dependent].to_s)
15
+ end
16
+ @name = options[:as] || options[:other]
17
+ @type = type
18
+ @object_kind = options[:other].to_s.singularize.to_sym
19
+ @self_kind = options[:self].to_s.singularize.to_sym
20
+ @alias = options[:alias] || options[:self]
21
+ @dependent = options[:dependent]
22
+ end
23
+
24
+ def object_class
25
+ @object_kind.to_s.camelize.constantize
26
+ end
27
+
28
+ def referrer_id
29
+ return @referrer_id unless @referrer_id.nil?
30
+ referrer = type == :referenced_in ? @name.to_s : @alias.to_s.singularize
31
+ @referrer_id = referrer.foreign_key
32
+ end
33
+
34
+ private
35
+
36
+ def find_missing_args(options)
37
+ MANDATORY_ARGS - options.keys
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,59 @@
1
+ require "ricordami/key_namer"
2
+
3
+ module Ricordami
4
+ class UniqueIndex
5
+ SEPARATOR = "_-::-_"
6
+
7
+ attr_reader :model, :fields, :name, :need_get_by
8
+
9
+ def initialize(model, fields, options = {})
10
+ @model = model
11
+ @fields = [fields].flatten.map(&:to_sym)
12
+ @need_get_by = options[:get_by] && @fields != [:id]
13
+ @name = @fields.join("_").to_sym
14
+ end
15
+
16
+ def uidx_key_name
17
+ @uidx_key_name ||= KeyNamer.unique_index(@model, :name => @name)
18
+ end
19
+
20
+ def ref_key_name
21
+ @ref_key_name ||= KeyNamer.hash_ref(@model, :fields => @fields)
22
+ end
23
+
24
+ def add(id, value)
25
+ value = value.join(SEPARATOR) if value.is_a?(Array)
26
+ @model.redis.sadd(uidx_key_name, value)
27
+ @model.redis.hset(ref_key_name, value, id) if @need_get_by
28
+ end
29
+
30
+ def rem(id, value, return_command = false)
31
+ if return_command
32
+ commands = []
33
+ commands << [:hdel, [ref_key_name, id]] if @need_get_by
34
+ commands << [:srem, [uidx_key_name, value]]
35
+ return commands
36
+ end
37
+ @model.redis.hdel(ref_key_name, id) if @need_get_by
38
+ value = value.join(SEPARATOR) if value.is_a?(Array)
39
+ @model.redis.srem(uidx_key_name, value)
40
+ end
41
+
42
+ def id_for_values(*values)
43
+ values = values.flatten.join(SEPARATOR)
44
+ @model.redis.hget(ref_key_name, values)
45
+ end
46
+
47
+ def all
48
+ @model.redis.smembers(uidx_key_name)
49
+ end
50
+
51
+ def count
52
+ @model.redis.scard(uidx_key_name)
53
+ end
54
+
55
+ def include?(value)
56
+ @model.redis.sismember(uidx_key_name, value)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ require "active_model/validator"
2
+
3
+ module Ricordami
4
+ class UniqueValidator < ActiveModel::EachValidator
5
+ def validate_each(record, attribute, value)
6
+ return true unless record.new_record? || record.send(:attribute_changed?, attribute)
7
+ index_name = attribute.to_sym
8
+ index = record.class.indices[index_name]
9
+ if index.include?(value)
10
+ attr_def = record.class.attributes[attribute]
11
+ unless record.persisted? && attr_def.read_only?
12
+ record.errors.add(attribute, options[:message] || "is already used")
13
+ end
14
+ end
15
+ end
16
+
17
+ def setup(klass)
18
+ attributes.each { |attribute| klass.index :unique => attribute }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ require "ricordami/key_namer"
2
+
3
+ module Ricordami
4
+ class ValueIndex
5
+ attr_reader :model, :field, :name
6
+
7
+ def initialize(model, field)
8
+ @model = model
9
+ @field = field.to_sym
10
+ @name = @field
11
+ end
12
+
13
+ def key_name_for_value(value)
14
+ KeyNamer.index(@model, :field => @field, :value => value)
15
+ end
16
+
17
+ def add(id, value)
18
+ @model.redis.sadd(key_name_for_value(value), id)
19
+ end
20
+
21
+ def rem(id, value, return_command = false)
22
+ return [[:srem, [key_name_for_value(value), id]]] if return_command
23
+ @model.redis.srem(key_name_for_value(value), id)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,3 @@
1
+ module Ricordami
2
+ VERSION = "0.0.1"
3
+ end
data/lib/ricordami.rb ADDED
@@ -0,0 +1,26 @@
1
+ require "digest/sha1"
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/hash/slice"
5
+ require "active_support/core_ext/hash/keys"
6
+ require "active_support/core_ext/hash/indifferent_access"
7
+ require "active_support/core_ext/object/blank"
8
+ require "active_model"
9
+ require "active_model/naming"
10
+ require "active_model/conversion"
11
+ require "active_model/attribute_methods"
12
+ require "redis"
13
+
14
+ module Ricordami
15
+ extend self
16
+ end
17
+
18
+ require "ricordami/exceptions"
19
+ require "ricordami/configuration"
20
+ require "ricordami/connection"
21
+ require "ricordami/has_attributes"
22
+ require "ricordami/has_indices"
23
+ require "ricordami/is_lockable"
24
+ require "ricordami/is_persisted"
25
+ require "ricordami/is_retrievable"
26
+ require "ricordami/model"
@@ -0,0 +1,42 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require "acceptance_helper"
3
+ require "ricordami/can_have_relationships"
4
+
5
+ class Performer
6
+ include Ricordami::Model
7
+ include Ricordami::CanHaveRelationships
8
+
9
+ attribute :first_name
10
+ attribute :last_name
11
+
12
+ references_many :albums, :dependent => :delete
13
+ end
14
+
15
+ class Album
16
+ include Ricordami::Model
17
+ include Ricordami::CanHaveRelationships
18
+
19
+ attribute :title
20
+ attribute :year, :indexed => :value
21
+
22
+ referenced_in :performer
23
+ end
24
+
25
+ feature "Manage relationships" do
26
+ scenario "has many / belongs to" do
27
+ serge = Performer.create(:first_name => "Serge", :last_name => "Gainsbourg")
28
+ serge.albums.create(:title => "Melody Nelson", :year => "1971").should be_true
29
+
30
+ marseillaise = serge.albums.build(:title => "Aux Armes et cætera", :year => "1979")
31
+ marseillaise.save.should be_true
32
+
33
+ performer = Performer.first
34
+ performer.albums.map(&:title).should =~ ["Melody Nelson", "Aux Armes et cætera"]
35
+ performer.albums.where(:year => "1979").map(&:title).should =~ ["Aux Armes et cætera"]
36
+ performer.albums.sort(:year, :desc_num).map(&:title).should == ["Aux Armes et cætera", "Melody Nelson"]
37
+
38
+ Album.all.map(&:title).should =~ ["Melody Nelson", "Aux Armes et cætera"]
39
+ performer.delete
40
+ Album.all.should be_empty
41
+ end
42
+ end
@@ -0,0 +1,78 @@
1
+ require "acceptance_helper"
2
+ require "ricordami/can_be_validated"
3
+
4
+ class Singer
5
+ include Ricordami::Model
6
+ include Ricordami::CanBeValidated
7
+
8
+ attribute :username
9
+ attribute :email
10
+ attribute :first_name
11
+ attribute :last_name
12
+ attribute :deceased, :default => "false", :indexed => :value
13
+
14
+ index :unique => :username, :get_by => true
15
+
16
+ validates_presence_of :username, :email, :deceased
17
+ validates_uniqueness_of :username
18
+ validates_format_of :email, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i,
19
+ :allow_blank => true, :message => "is not a valid email"
20
+ validates_inclusion_of :deceased, :in => ["true", "false"]
21
+ end
22
+
23
+ feature "Basic model features with validation" do
24
+ scenario "validate, update, save and delete a model" do
25
+ serge = Singer.new
26
+ serge.should_not be_valid
27
+ serge.should have(2).errors
28
+ serge.errors.full_messages.should =~ ["Email can't be blank", "Username can't be blank"]
29
+
30
+ serge.email = "what's up?"
31
+ serge.save.should be_false
32
+ serge.errors[:email].should == ["is not a valid email"]
33
+
34
+ serge.update_attributes(:username => "lucien", :email => "serge@gainsbourg.com",
35
+ :first_name => "Serge", :last_name => "Gainsbourg")
36
+ serge.should be_valid
37
+ serge.should be_persisted
38
+ serge.should_not be_a_new_record
39
+ serge.reload.should be_persisted
40
+
41
+ lucien = Singer.new(:username => "lucien", :email => "lucien@blahblah.com")
42
+ lucien.should_not be_valid
43
+ lucien.errors.full_messages.should == ["Username is already used"]
44
+ lucien.username = "lucien2"
45
+ lucien.save.should be_true
46
+ lucien.should be_persisted
47
+
48
+ Singer.count.should == 2
49
+ deleteme = Singer.get_by_username("lucien2")
50
+ deleteme.delete
51
+ deleteme.should be_deleted
52
+ Singer.count.should == 1
53
+ Singer.get_by_username("lucien").should be_persisted
54
+ end
55
+
56
+ scenario "dirty state of models" do
57
+ Singer.create(:username => "bashung", :email => "alain@bashung.com",
58
+ :first_name => "Alain", :last_name => "Bashung")
59
+ alain = Singer.get_by_username("bashung")
60
+ alain.changed?.should be_false
61
+
62
+ alain.first_name = "Bob"
63
+ alain.changed?.should be_true
64
+ alain.first_name_changed?.should be_true
65
+ alain.first_name_was.should == "Alain"
66
+ alain.first_name_change.should == ["Alain", "Bob"]
67
+ alain.changed.should == ["first_name"]
68
+ alain.changes.should == {"first_name" => ["Alain", "Bob"]}
69
+
70
+ alain.save
71
+ alain.changed?.should be_false
72
+ alain.first_name_changed?.should be_false
73
+ alain.first_name = "Bob"
74
+ alain.changed?.should be_false
75
+ alain.first_name_changed?.should be_false
76
+ alain.previous_changes.should == {"first_name" => ["Alain", "Bob"]}
77
+ end
78
+ end