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 +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
|
+
[![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
|
-
*
|
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"
|