secretary-rails 1.0.0.beta1 → 1.0.0.beta2
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.
- data/README.md +49 -17
- data/lib/generators/secretary/install_generator.rb +1 -1
- data/lib/secretary/dirty/collection_association.rb +74 -0
- data/lib/secretary/dirty/singular_association.rb +84 -0
- data/lib/secretary/dirty.rb +8 -0
- data/lib/secretary/errors.rb +22 -6
- data/lib/secretary/gem_version.rb +1 -1
- data/lib/secretary/has_secretary.rb +13 -3
- data/lib/secretary/tracks_association.rb +33 -75
- data/lib/secretary/versioned_attributes.rb +0 -3
- data/lib/secretary.rb +1 -0
- data/spec/factories.rb +11 -0
- data/spec/internal/app/models/animal.rb +1 -3
- data/spec/internal/app/models/car.rb +2 -0
- data/spec/internal/app/models/image.rb +6 -0
- data/spec/internal/app/models/person.rb +2 -6
- data/spec/internal/app/models/story.rb +4 -0
- data/spec/internal/db/combustion_test.sqlite +0 -0
- data/spec/internal/db/schema.rb +14 -0
- data/spec/internal/log/test.log +8329 -0
- data/spec/lib/secretary/dirty/collection_association_spec.rb +186 -0
- data/spec/lib/secretary/dirty/singular_association_spec.rb +248 -0
- data/spec/lib/secretary/has_secretary_spec.rb +9 -0
- data/spec/lib/secretary/tracks_association_spec.rb +6 -198
- data/spec/spec_helper.rb +1 -1
- metadata +29 -18
- /data/spec/tmp/db/migrate/{20131105082639_create_versions.rb → 20131106020537_create_versions.rb} +0 -0
data/README.md
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# Secretary
|
2
2
|
|
3
|
+
[](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
|
-
*
|
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
|
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
|
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
|
-
|
174
|
+
# Inclusion
|
175
|
+
has_secretary on: ["headline", "body"]
|
176
|
+
end
|
177
|
+
```
|
150
178
|
|
151
|
-
|
152
|
-
|
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
|
-
|
161
|
-
|
197
|
+
has_many :images
|
198
|
+
tracks_association :images
|
162
199
|
end
|
163
|
-
```
|
164
200
|
|
165
|
-
|
166
|
-
|
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
|
-
*
|
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
|
@@ -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
|
data/lib/secretary/errors.rb
CHANGED
@@ -1,10 +1,26 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
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
|
-
|
7
|
-
|
8
|
-
|
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
|
@@ -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
|
-
#
|
12
|
-
|
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
|
17
|
-
# :class_name
|
18
|
-
# :dependent
|
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
|
-
|
59
|
-
def #{name}_were
|
60
|
-
@#{name}_were ||= association_was("#{name}")
|
61
|
-
end
|
54
|
+
reflection = self.reflect_on_association(name)
|
62
55
|
|
63
|
-
|
64
|
-
|
65
|
-
|
56
|
+
if !reflection
|
57
|
+
raise NoAssociationError, name, self.name
|
58
|
+
end
|
66
59
|
|
60
|
+
self.versioned_attributes << name.to_s
|
67
61
|
|
68
|
-
|
62
|
+
if reflection.collection?
|
63
|
+
include Dirty::CollectionAssociation
|
64
|
+
add_dirty_collection_association_methods(name)
|
69
65
|
|
70
|
-
|
71
|
-
#{name}
|
72
|
-
end
|
66
|
+
add_callback_methods(:before_add, reflection,
|
67
|
+
[:"preload_#{name}"])
|
73
68
|
|
74
|
-
|
75
|
-
|
76
|
-
end
|
69
|
+
add_callback_methods(:before_remove, reflection,
|
70
|
+
[:"preload_#{name}"])
|
77
71
|
|
78
|
-
|
79
|
-
|
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
|
-
|
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(
|
102
|
-
|
103
|
-
|
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
|
-
|
139
|
-
|
140
|
-
|
141
|
-
Array.
|
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
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,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
|
Binary file
|
data/spec/internal/db/schema.rb
CHANGED
@@ -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"
|