secretary-rails 1.0.0.beta1 → 1.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Secretary
2
2
 
3
+ [![Build Status](https://circleci.com/gh/SCPR/secretary-rails.png?circle-token=f1fadff9935d408e019bf9599b68aaf7a406d13b)](https://circleci.com/gh/SCPR/secretary-rails)
4
+
3
5
  #### A note about the Gem name
4
6
  There is another gem called [`secretary`](http://rubygems.org/gems/secretary),
5
7
  which hasn't been updated since 2008 and is obsolete. This gem is called
@@ -25,7 +27,7 @@ foreign keys to the object, and a foreign key to the user who saved the object.
25
27
  * Rails 3.2+
26
28
  * SQLite
27
29
  * MySQL? (untested)
28
- * Postgres? (untested)
30
+ * PostgreSQL? (untested)
29
31
 
30
32
  ### Dependencies
31
33
  * [`activerecord`](http://rubygems.org/gems/activerecord) >= 3.2.0
@@ -41,7 +43,7 @@ gem 'secretary-rails'
41
43
  ```
42
44
 
43
45
  Run the install command, which will create a migration to add the `versions`
44
- table, and then run it:
46
+ table, and then run the migration:
45
47
 
46
48
  ```
47
49
  bundle exec rails generate secretary:install
@@ -92,6 +94,27 @@ changes, which will include the information about the author(s).
92
94
 
93
95
  You can also pass in multiple association names into `tracks_association`.
94
96
 
97
+ ### Dirty Associations
98
+ Secretary provides Rails-style `dirty attributes` for associations.
99
+ Given an association `has_many :pets`, the methods available are:
100
+
101
+ * **pets_changed?**
102
+ * **pets_were**
103
+
104
+ Secretary also merges in the association changes into the standard Rails
105
+ `changes` hash:
106
+
107
+ ```ruby
108
+ person.pets.to_a # => []
109
+
110
+ person.pets << Pet.new(name: "Spot")
111
+
112
+ person.pets_changed? # => true
113
+ person.changed? # => true
114
+ person.pets_were # => []
115
+ person.changes # => { "pets" => [[], [{ "name" => "Spot" }]]}
116
+ ```
117
+
95
118
  ### Tracking Users
96
119
  A version has an association to a user object, which tells you who created that
97
120
  version. The logged user is an attribute on the object being changed, so you
@@ -142,43 +165,52 @@ Sometimes you have an attribute on your model that either isn't public
142
165
  (not in the form), or you just don't want to version. You can tell Secretary
143
166
  to ignore these attributes globally by setting
144
167
  `Secretary.config.ignore_attributes`. You can also ignore attributes on a
145
- per-model basis by using one of two methods:
168
+ per-model basis by using one of two options:
169
+
170
+ **NOTE** The attributes *must* be specified as Strings.
146
171
 
147
172
  ```ruby
148
173
  class Article < ActiveRecord::Base
149
- has_secretary
174
+ # Inclusion
175
+ has_secretary on: ["headline", "body"]
176
+ end
177
+ ```
150
178
 
151
- # Included
152
- self.versioned_attributes = ["headline", "body"]
179
+ ```ruby
180
+ class Article < ActiveRecord::Base
181
+ # Exclusion
182
+ has_secretary except: ["published_at", "is_editable"]
153
183
  end
154
184
  ```
155
185
 
186
+ By default, the versioned attributes are: the model's column names, minus the
187
+ globally configured `ignored_attributes`, minus any excluded attributes
188
+ you have set.
189
+
190
+ Using `tracks_association` adds those associations to the
191
+ `versioned_attributes` array:
192
+
156
193
  ```ruby
157
194
  class Article < ActiveRecord::Base
158
- has_secretary
195
+ has_secretary on: ["headline"]
159
196
 
160
- # Excluded
161
- self.unversioned_attributes = ["published_at", "is_editable"]
197
+ has_many :images
198
+ tracks_association :images
162
199
  end
163
- ```
164
200
 
165
- By default, `versioned_attributes` is the model's column names, minus the
166
- globally configured `ignored_attributes`, minus any `unversioned_attributes`
167
- you have set. `tracks_association` adds those associations to the
168
- `versioned_attributes` array.
201
+ Article.versioned_attributes # => ["headline", "images"]
202
+ ```
169
203
 
170
204
 
171
205
  ## Contributing
172
206
  Fork it and send a pull request!
173
207
 
174
208
  ### TODO
175
- * Rails 4.1+ support.
176
- * Test (officially) with MySQL and SQLite.
209
+ * See [Issues](https://github.com/SCPR/secretary-rails/issues).
177
210
  * Associations are only tracked one-level deep, It would be nice to also
178
211
  track the changes of the association (i.e. recognize when an associated
179
212
  object was changed and show its changed, instead of just showing a whole
180
213
  new object).
181
- * Support for Rails 3.0 and 3.1.
182
214
 
183
215
  ### Running Tests
184
216
  This library uses [appraisal](https://github.com/thoughtbot/appraisal) to test
@@ -1,4 +1,4 @@
1
- require 'rails/generators'
1
+ require 'rails/generators/base'
2
2
 
3
3
  module Secretary
4
4
  class InstallGenerator < Rails::Generators::Base
@@ -0,0 +1,74 @@
1
+ module Secretary
2
+ module Dirty
3
+ module CollectionAssociation
4
+ extend ActiveSupport::Concern
5
+
6
+ module ClassMethods
7
+ private
8
+
9
+ def add_dirty_collection_association_methods(name)
10
+ module_eval <<-EOE, __FILE__, __LINE__ + 1
11
+ def #{name}_were
12
+ @#{name}_were ||= collection_association_was("#{name}")
13
+ end
14
+
15
+ def #{name}_changed?
16
+ collection_association_changed?("#{name}")
17
+ end
18
+
19
+
20
+ private
21
+
22
+ def preload_#{name}(object)
23
+ #{name}_were
24
+ end
25
+
26
+ def check_for_#{name}_changes
27
+ check_for_collection_association_changes("#{name}")
28
+ end
29
+
30
+ def clear_dirty_#{name}
31
+ @#{name}_were = nil
32
+ end
33
+ EOE
34
+ end
35
+ end
36
+
37
+
38
+ private
39
+
40
+ # This has to be run in a before_save callback,
41
+ # because we can't rely on the after_add, etc. callbacks
42
+ # to fill in our custom changes. For example, setting
43
+ # `self.animals_attributes=` doesn't run these callbacks.
44
+ def check_for_collection_association_changes(name)
45
+ persisted = self.send("#{name}_were")
46
+ current = self.send(name).to_a.reject(&:marked_for_destruction?)
47
+
48
+ persisted_attributes = persisted.map(&:versioned_attributes)
49
+ current_attributes = current.map(&:versioned_attributes)
50
+
51
+ if persisted_attributes != current_attributes
52
+ ensure_custom_changes_for_collection_association(name, persisted)
53
+ self.custom_changes[name][1] = current_attributes
54
+ end
55
+ end
56
+
57
+ def collection_association_was(name)
58
+ self.persisted? ? self.class.find(self.id).send(name).to_a : []
59
+ end
60
+
61
+ def collection_association_changed?(name)
62
+ check_for_collection_association_changes(name)
63
+ self.custom_changes[name].present?
64
+ end
65
+
66
+ def ensure_custom_changes_for_collection_association(name, persisted=nil)
67
+ self.custom_changes[name] ||= [
68
+ (persisted || self.send("#{name}_were")).map(&:versioned_attributes),
69
+ Array.new
70
+ ]
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,84 @@
1
+ module Secretary
2
+ module Dirty
3
+ module SingularAssociation
4
+ extend ActiveSupport::Concern
5
+
6
+ module ClassMethods
7
+ private
8
+
9
+ def add_dirty_singular_association_methods(name)
10
+ module_eval <<-EOE, __FILE__, __LINE__ + 1
11
+ def #{name}_was
12
+ if defined?(@#{name}_was)
13
+ @#{name}_was
14
+ else
15
+ @#{name}_was = singular_association_was("#{name}")
16
+ end
17
+ end
18
+
19
+ def #{name}_changed?
20
+ singular_association_changed?("#{name}")
21
+ end
22
+
23
+ def #{name}=(value)
24
+ preload_#{name}
25
+ obj = super
26
+ check_for_#{name}_changes
27
+ obj
28
+ end
29
+
30
+ private
31
+
32
+ def preload_#{name}(object=nil)
33
+ #{name}_was
34
+ end
35
+
36
+ def check_for_#{name}_changes
37
+ check_for_singular_association_changes("#{name}")
38
+ end
39
+
40
+ def clear_dirty_#{name}
41
+ remove_instance_variable(:@#{name}_was)
42
+ end
43
+ EOE
44
+ end
45
+ end
46
+
47
+
48
+ private
49
+
50
+ # This has to be run in a before_save callback,
51
+ # because we can't rely on the after_add, etc. callbacks
52
+ # to fill in our custom changes. For example, setting
53
+ # `self.animals_attributes=` doesn't run these callbacks.
54
+ def check_for_singular_association_changes(name)
55
+ persisted = self.send("#{name}_was")
56
+ current = self.send(name)
57
+
58
+ persisted_attributes = persisted ? persisted.versioned_attributes : {}
59
+
60
+ current_attributes = if current && !current.marked_for_destruction?
61
+ current.versioned_attributes
62
+ else
63
+ {}
64
+ end
65
+
66
+ if persisted_attributes != current_attributes
67
+ self.custom_changes[name] = [
68
+ persisted_attributes,
69
+ current_attributes
70
+ ]
71
+ end
72
+ end
73
+
74
+ def singular_association_was(name)
75
+ self.persisted? ? self.class.find(self.id).send(name) : nil
76
+ end
77
+
78
+ def singular_association_changed?(name)
79
+ check_for_singular_association_changes(name)
80
+ self.custom_changes[name].present?
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,8 @@
1
+ module Secretary
2
+ module Dirty
3
+ extend ActiveSupport::Autoload
4
+
5
+ autoload :CollectionAssociation
6
+ autoload :SingularAssociation
7
+ end
8
+ end
@@ -1,10 +1,26 @@
1
- class Secretary::NotVersionedError < StandardError
2
- def initialize(klasses=nil)
3
- @klasses = Array(klasses)
1
+ module Secretary
2
+ class NotVersionedError < StandardError
3
+ def initialize(klass=nil)
4
+ @klass = klass
5
+ end
6
+
7
+ def message
8
+ "Can't track an association on an unversioned model " \
9
+ "(#{@klass}) Did you declare `has_secretary` first?"
10
+ end
4
11
  end
5
12
 
6
- def message
7
- "Can't track an association on an unversioned model " \
8
- "(#{@klasses.join(", ")}) Did you declare `has_secretary` first?"
13
+
14
+ class NoAssociationError < StandardError
15
+ def initialize(name=nil, klass=nil)
16
+ @name = name
17
+ @klass = klass
18
+ end
19
+
20
+ def message
21
+ "There is no association named #{@name} for the class #{@klass}. " \
22
+ "Check that you've already declared the association before calling " \
23
+ "'tracks_association', and that you use symbols."
24
+ end
9
25
  end
10
26
  end
@@ -1,3 +1,3 @@
1
1
  module Secretary
2
- GEM_VERSION = "1.0.0.beta1"
2
+ GEM_VERSION = "1.0.0.beta2"
3
3
  end
@@ -3,16 +3,26 @@ module Secretary
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  module ClassMethods
6
- # Check if a class is versioned
6
+ # (Boolean) Check if a class is versioned
7
+ # Story.has_secretary? # => true or false
7
8
  def has_secretary?
8
9
  !!@_has_secretary
9
10
  end
10
11
 
11
- # Apply to any class that should be versioned
12
- def has_secretary
12
+ # Declare that this class shoudl be versioned.
13
+ #
14
+ # Options:
15
+ # * `on` : Array of Strings which specifies which attributes should be
16
+ # versioned.
17
+ # * `except` : Array of Strings which specifies which attributes should
18
+ # NOT be versioned.
19
+ def has_secretary(options={})
13
20
  @_has_secretary = true
14
21
  Secretary.versioned_models.push self.name
15
22
 
23
+ self.versioned_attributes = options[:on] if options[:on]
24
+ self.unversioned_attributes = options[:except] if options[:except]
25
+
16
26
  has_many :versions,
17
27
  :class_name => "Secretary::Version",
18
28
  :as => :versioned,
@@ -13,9 +13,9 @@ module Secretary
13
13
  # has_secretary
14
14
  #
15
15
  # has_many :bylines,
16
- # :as => :content,
17
- # :class_name => "ContentByline",
18
- # :dependent => :destroy
16
+ # :as => :content,
17
+ # :class_name => "ContentByline",
18
+ # :dependent => :destroy
19
19
  #
20
20
  # tracks_association :bylines
21
21
  #
@@ -50,96 +50,54 @@ module Secretary
50
50
  raise NotVersionedError, self.name
51
51
  end
52
52
 
53
- self.versioned_attributes += associations.map(&:to_s)
54
-
55
- include InstanceMethodsOnActivation
56
-
57
53
  associations.each do |name|
58
- module_eval <<-EOE, __FILE__, __LINE__ + 1
59
- def #{name}_were
60
- @#{name}_were ||= association_was("#{name}")
61
- end
54
+ reflection = self.reflect_on_association(name)
62
55
 
63
- def #{name}_changed?
64
- association_changed?("#{name}")
65
- end
56
+ if !reflection
57
+ raise NoAssociationError, name, self.name
58
+ end
66
59
 
60
+ self.versioned_attributes << name.to_s
67
61
 
68
- private
62
+ if reflection.collection?
63
+ include Dirty::CollectionAssociation
64
+ add_dirty_collection_association_methods(name)
69
65
 
70
- def preload_#{name}_were(object)
71
- #{name}_were
72
- end
66
+ add_callback_methods(:before_add, reflection,
67
+ [:"preload_#{name}"])
73
68
 
74
- def check_for_#{name}_changes
75
- check_for_association_changes("#{name}")
76
- end
69
+ add_callback_methods(:before_remove, reflection,
70
+ [:"preload_#{name}"])
77
71
 
78
- def clear_dirty_#{name}
79
- @#{name}_were = nil
72
+ if ActiveRecord::VERSION::STRING >= "4.1.0"
73
+ ActiveRecord::Associations::Builder::CollectionAssociation
74
+ .define_callbacks(self, reflection)
75
+ else
76
+ redefine_callback(:before_add, name, reflection)
77
+ redefine_callback(:before_remove, name, reflection)
80
78
  end
81
- EOE
79
+ else
80
+ include Dirty::SingularAssociation
81
+ add_dirty_singular_association_methods(name)
82
+ end
82
83
 
83
84
  before_save :"check_for_#{name}_changes"
84
85
  after_commit :"clear_dirty_#{name}"
85
-
86
- add_callback_methods("before_add_for_#{name}", [
87
- :"preload_#{name}_were"
88
- ])
89
-
90
- add_callback_methods("before_remove_for_#{name}", [
91
- :"preload_#{name}_were"
92
- ])
93
-
94
- add_callback_methods("after_add_for_#{name}", [])
95
- add_callback_methods("after_remove_for_#{name}", [])
96
86
  end
97
87
  end
98
88
 
99
89
  private
100
90
 
101
- def add_callback_methods(method_name, new_methods)
102
- original = send(method_name)
103
- methods = original + new_methods
104
- send("#{method_name}=", methods)
105
- end
106
- end
107
-
108
-
109
- module InstanceMethodsOnActivation
110
- private
111
-
112
- # This has to be run in a before_save callback,
113
- # because we can't rely on the after_add, etc. callbacks
114
- # to fill in our custom changes. For example, setting
115
- # `self.animals_attributes=` doesn't run these callbacks.
116
- def check_for_association_changes(name)
117
- persisted = self.send("#{name}_were")
118
- current = self.send(name).to_a.reject(&:marked_for_destruction?)
119
-
120
- persisted_attributes = persisted.map(&:versioned_attributes)
121
- current_attributes = current.map(&:versioned_attributes)
122
-
123
- if persisted_attributes != current_attributes
124
- ensure_custom_changes_for_association(name, persisted)
125
- self.custom_changes[name][1] = current_attributes
126
- end
127
- end
128
-
129
- def association_was(name)
130
- self.persisted? ? self.class.find(self.id).send(name).to_a : []
131
- end
132
-
133
- def association_changed?(name)
134
- check_for_association_changes(name)
135
- self.custom_changes[name].present?
91
+ def add_callback_methods(callback_name, reflection, new_methods)
92
+ reflection.options[callback_name] ||= Array.new
93
+ reflection.options[callback_name] += new_methods
136
94
  end
137
95
 
138
- def ensure_custom_changes_for_association(name, persisted=nil)
139
- self.custom_changes[name] ||= [
140
- (persisted || self.send("#{name}_were")).map(&:versioned_attributes),
141
- Array.new
142
- ]
96
+ # Necessary for Rails < 4.1
97
+ def redefine_callback(callback_name, name, reflection)
98
+ send("#{callback_name}_for_#{name}=",
99
+ Array(reflection.options[callback_name])
100
+ )
143
101
  end
144
102
  end
145
103
  end
@@ -16,9 +16,6 @@ module Secretary
16
16
  # you can set `unversioned_attributes` to tell Secretary
17
17
  # which attributes to ignore.
18
18
  #
19
- # Note: These should be set before any `tracks_association`
20
- # macros are called.
21
- #
22
19
  # Each takes an array of column names *as strings*.
23
20
  attr_writer :versioned_attributes
24
21
 
data/lib/secretary.rb CHANGED
@@ -31,6 +31,7 @@ module Secretary
31
31
  autoload :HasSecretary
32
32
  autoload :VersionedAttributes
33
33
  autoload :TracksAssociation
34
+ autoload :Dirty
34
35
  end
35
36
 
36
37
  ActiveSupport.on_load(:active_record) do
data/spec/factories.rb CHANGED
@@ -5,6 +5,17 @@ FactoryGirl.define do
5
5
  color "gray"
6
6
  end
7
7
 
8
+ factory :car do
9
+ name "Betsy"
10
+ color "white"
11
+ year 1984
12
+ end
13
+
14
+ factory :image do
15
+ title "Obama"
16
+ url "http://obama.com/obama.jpg"
17
+ end
18
+
8
19
  factory :location do
9
20
  title "Crawford Family Forum"
10
21
  address "474 S. Raymond, Pasadena"
@@ -1,7 +1,5 @@
1
1
  class Animal < ActiveRecord::Base
2
- has_secretary
2
+ has_secretary on: ["name", "color"]
3
3
 
4
4
  belongs_to :person
5
-
6
- self.versioned_attributes = ["name", "color"]
7
5
  end
@@ -0,0 +1,2 @@
1
+ class Car < ActiveRecord::Base
2
+ end
@@ -0,0 +1,6 @@
1
+ class Image < ActiveRecord::Base
2
+ has_secretary on: ["title", "url"]
3
+
4
+ belongs_to :story
5
+ tracks_association :story
6
+ end
@@ -1,14 +1,10 @@
1
1
  class Person < ActiveRecord::Base
2
- has_secretary
2
+ has_secretary except: ["name", "ethnicity"]
3
3
 
4
4
  belongs_to :location
5
-
6
5
  has_many :animals
7
- accepts_nested_attributes_for :animals, allow_destroy: true
8
-
9
6
  has_many :hobbies
10
7
 
8
+ accepts_nested_attributes_for :animals, allow_destroy: true
11
9
  tracks_association :animals, :hobbies
12
-
13
- self.unversioned_attributes = ["name", "ethnicity"]
14
10
  end
@@ -1,3 +1,7 @@
1
1
  class Story < ActiveRecord::Base
2
2
  has_secretary
3
+
4
+ has_one :image
5
+ accepts_nested_attributes_for :image, allow_destroy: true
6
+ tracks_association :image
3
7
  end
@@ -7,12 +7,26 @@ ActiveRecord::Schema.define do
7
7
  t.timestamps
8
8
  end
9
9
 
10
+ create_table "cars", force: true do |t|
11
+ t.string "name"
12
+ t.string "color"
13
+ t.integer "year"
14
+ t.timestamps
15
+ end
16
+
10
17
  create_table "hobbies", force: true do |t|
11
18
  t.string "title"
12
19
  t.integer "person_id"
13
20
  t.timestamps
14
21
  end
15
22
 
23
+ create_table "images", force: true do |t|
24
+ t.string "title"
25
+ t.string "url"
26
+ t.integer "story_id"
27
+ t.timestamps
28
+ end
29
+
16
30
  create_table "locations", force: true do |t|
17
31
  t.string "title"
18
32
  t.text "address"