ricordami 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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