secretary-rails 1.0.0.beta1
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/MIT-LICENSE +20 -0
- data/README.md +186 -0
- data/Rakefile +9 -0
- data/app/models/secretary/version.rb +93 -0
- data/lib/generators/secretary/install_generator.rb +23 -0
- data/lib/generators/secretary/templates/versions_migration.rb +18 -0
- data/lib/secretary/config.rb +19 -0
- data/lib/secretary/engine.rb +4 -0
- data/lib/secretary/errors.rb +10 -0
- data/lib/secretary/gem_version.rb +3 -0
- data/lib/secretary/has_secretary.rb +75 -0
- data/lib/secretary/tracks_association.rb +146 -0
- data/lib/secretary/versioned_attributes.rb +58 -0
- data/lib/secretary-rails.rb +3 -0
- data/lib/secretary.rb +40 -0
- data/lib/tasks/secretary_tasks.rake +6 -0
- data/spec/factories.rb +32 -0
- data/spec/internal/app/models/animal.rb +7 -0
- data/spec/internal/app/models/hobby.rb +3 -0
- data/spec/internal/app/models/location.rb +6 -0
- data/spec/internal/app/models/person.rb +14 -0
- data/spec/internal/app/models/story.rb +3 -0
- data/spec/internal/app/models/user.rb +3 -0
- data/spec/internal/config/database.yml +3 -0
- data/spec/internal/config/routes.rb +2 -0
- data/spec/internal/db/combustion_test.sqlite +0 -0
- data/spec/internal/db/schema.rb +50 -0
- data/spec/internal/log/test.log +23177 -0
- data/spec/lib/generators/secretary/install_generator_spec.rb +17 -0
- data/spec/lib/secretary/config_spec.rb +9 -0
- data/spec/lib/secretary/has_secretary_spec.rb +116 -0
- data/spec/lib/secretary/tracks_association_spec.rb +214 -0
- data/spec/lib/secretary/versioned_attributes_spec.rb +63 -0
- data/spec/lib/secretary_spec.rb +44 -0
- data/spec/models/secretary/version_spec.rb +68 -0
- data/spec/spec_helper.rb +20 -0
- data/spec/tmp/db/migrate/20131105082639_create_versions.rb +18 -0
- metadata +181 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2013 Bryan Ricker, SCPR
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,186 @@
|
|
1
|
+
# Secretary
|
2
|
+
|
3
|
+
#### A note about the Gem name
|
4
|
+
There is another gem called [`secretary`](http://rubygems.org/gems/secretary),
|
5
|
+
which hasn't been updated since 2008 and is obsolete. This gem is called
|
6
|
+
`secretary-rails` since `secretary` is already taken on RubyGems. However, the
|
7
|
+
module name is the same, so using them together would be difficult.
|
8
|
+
|
9
|
+
|
10
|
+
### What is it?
|
11
|
+
Light-weight model versioning for ActiveRecord 3.2+.
|
12
|
+
|
13
|
+
### How does it work?
|
14
|
+
Whenever you save your model, a new version is saved. The changes are
|
15
|
+
serialized and stored in the database, along with a version description,
|
16
|
+
foreign keys to the object, and a foreign key to the user who saved the object.
|
17
|
+
|
18
|
+
### Why is it better than [other versioning gem]?
|
19
|
+
* It tracks associations.
|
20
|
+
* It provides diffs (using the [`diffy`](http://rubygems.org/gems/diffy) gem).
|
21
|
+
* It only stores the changes, not the whole object.
|
22
|
+
* It is simple.
|
23
|
+
|
24
|
+
### Compatibility
|
25
|
+
* Rails 3.2+
|
26
|
+
* SQLite
|
27
|
+
* MySQL? (untested)
|
28
|
+
* Postgres? (untested)
|
29
|
+
|
30
|
+
### Dependencies
|
31
|
+
* [`activerecord`](http://rubygems.org/gems/activerecord) >= 3.2.0
|
32
|
+
* [`railties`](http://rubygems.org/gems/railties) >= 3.2.0
|
33
|
+
* [`diffy`](http://rubygems.org/gems/diffy) ~> 3.0.1
|
34
|
+
|
35
|
+
|
36
|
+
## Installation
|
37
|
+
Add to your gemfile:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
gem 'secretary-rails'
|
41
|
+
```
|
42
|
+
|
43
|
+
Run the install command, which will create a migration to add the `versions`
|
44
|
+
table, and then run it:
|
45
|
+
|
46
|
+
```
|
47
|
+
bundle exec rails generate secretary:install
|
48
|
+
bundle exec rake db:migrate
|
49
|
+
```
|
50
|
+
|
51
|
+
|
52
|
+
## Usage
|
53
|
+
Add the `has_secretary` macro to your model:
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
class Article < ActiveRecord::Base
|
57
|
+
has_secretary
|
58
|
+
end
|
59
|
+
```
|
60
|
+
|
61
|
+
Congratulations, now your records are being versioned.
|
62
|
+
|
63
|
+
### Tracking associations
|
64
|
+
This gem is built with the end-user in mind, so it doesn't track hidden
|
65
|
+
associations (i.e. join models). However, you can tell it to track associated
|
66
|
+
objects WITHIN their parent object's version by using the `tracks_association`
|
67
|
+
macro. For example:
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
class Author < ActiveRecord::Base
|
71
|
+
has_many :article_authors
|
72
|
+
has_many :articles, through: :article_authors
|
73
|
+
end
|
74
|
+
|
75
|
+
class ArticleAuthor < ActiveRecord::Base
|
76
|
+
belongs_to :article
|
77
|
+
belongs_to :author
|
78
|
+
end
|
79
|
+
|
80
|
+
class Article < ActiveRecord::Base
|
81
|
+
has_secretary
|
82
|
+
|
83
|
+
has_many :article_authors
|
84
|
+
has_many :authors, through: :article_authors
|
85
|
+
tracks_association :authors
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
Now, when you save an `Article`, a new version won't be created for the
|
90
|
+
new `ArticleAuthor` object(s). Instead, an array will be added to the `Article`'s
|
91
|
+
changes, which will include the information about the author(s).
|
92
|
+
|
93
|
+
You can also pass in multiple association names into `tracks_association`.
|
94
|
+
|
95
|
+
### Tracking Users
|
96
|
+
A version has an association to a user object, which tells you who created that
|
97
|
+
version. The logged user is an attribute on the object being changed, so you
|
98
|
+
can add it in via the controller:
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
class ArticlesController < ApplicationControler
|
102
|
+
before_filter :get_object, only: [:show, :edit, :update, :destroy]
|
103
|
+
before_filter :inject_logged_user, only: [:update]
|
104
|
+
|
105
|
+
def create
|
106
|
+
@article = Article.new(article_params)
|
107
|
+
inject_logged_user
|
108
|
+
# ...
|
109
|
+
end
|
110
|
+
|
111
|
+
# ...
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def get_object
|
116
|
+
@article = Article.find(params[:id])
|
117
|
+
end
|
118
|
+
|
119
|
+
def inject_logged_user
|
120
|
+
@article.logged_user_id = @current_user.id
|
121
|
+
end
|
122
|
+
end
|
123
|
+
```
|
124
|
+
|
125
|
+
### Configuration
|
126
|
+
In an initializer (may we suggest `secretary.rb`?), add:
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
# This is a list of all the possible configurations and their defaults.
|
130
|
+
Secretary.configure do |config|
|
131
|
+
config.user_class = "::User"
|
132
|
+
config.ignored_attributes = ["id", "created_at", "updated_at"]
|
133
|
+
end
|
134
|
+
```
|
135
|
+
|
136
|
+
* **user_class** - The class for your user model.
|
137
|
+
* **ignored_attributes** - The attributes which should always be ignored
|
138
|
+
when generating a version, for every model, as an array of Strings.
|
139
|
+
|
140
|
+
### Specifying which attributes to keep track of
|
141
|
+
Sometimes you have an attribute on your model that either isn't public
|
142
|
+
(not in the form), or you just don't want to version. You can tell Secretary
|
143
|
+
to ignore these attributes globally by setting
|
144
|
+
`Secretary.config.ignore_attributes`. You can also ignore attributes on a
|
145
|
+
per-model basis by using one of two methods:
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
class Article < ActiveRecord::Base
|
149
|
+
has_secretary
|
150
|
+
|
151
|
+
# Included
|
152
|
+
self.versioned_attributes = ["headline", "body"]
|
153
|
+
end
|
154
|
+
```
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
class Article < ActiveRecord::Base
|
158
|
+
has_secretary
|
159
|
+
|
160
|
+
# Excluded
|
161
|
+
self.unversioned_attributes = ["published_at", "is_editable"]
|
162
|
+
end
|
163
|
+
```
|
164
|
+
|
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.
|
169
|
+
|
170
|
+
|
171
|
+
## Contributing
|
172
|
+
Fork it and send a pull request!
|
173
|
+
|
174
|
+
### TODO
|
175
|
+
* Rails 4.1+ support.
|
176
|
+
* Test (officially) with MySQL and SQLite.
|
177
|
+
* Associations are only tracked one-level deep, It would be nice to also
|
178
|
+
track the changes of the association (i.e. recognize when an associated
|
179
|
+
object was changed and show its changed, instead of just showing a whole
|
180
|
+
new object).
|
181
|
+
* Support for Rails 3.0 and 3.1.
|
182
|
+
|
183
|
+
### Running Tests
|
184
|
+
This library uses [appraisal](https://github.com/thoughtbot/appraisal) to test
|
185
|
+
against different Rails versions. To run the test suite on all versions, use
|
186
|
+
`appraisal rspec`.
|
data/Rakefile
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
module Secretary
|
2
|
+
class Version < ActiveRecord::Base
|
3
|
+
serialize :object_changes
|
4
|
+
|
5
|
+
belongs_to :versioned, polymorphic: true
|
6
|
+
belongs_to :user, class_name: Secretary.config.user_class
|
7
|
+
|
8
|
+
validates_presence_of :versioned
|
9
|
+
|
10
|
+
before_create :increment_version_number
|
11
|
+
|
12
|
+
|
13
|
+
class << self
|
14
|
+
# Builds a new version for the passed-in object
|
15
|
+
# Passed-in object is a dirty object.
|
16
|
+
# Version will be saved when the object is saved.
|
17
|
+
#
|
18
|
+
# If you must generate a version manually, this
|
19
|
+
# method should be used instead of `Version.create`.
|
20
|
+
# I didn't want to override the public ActiveRecord
|
21
|
+
# API.
|
22
|
+
def generate(object)
|
23
|
+
object.versions.create({
|
24
|
+
:user_id => object.logged_user_id,
|
25
|
+
:description => generate_description(object),
|
26
|
+
:object_changes => object.versioned_changes
|
27
|
+
})
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def generate_description(object)
|
34
|
+
if was_created?(object)
|
35
|
+
"Created #{object.class.name.titleize} ##{object.id}"
|
36
|
+
|
37
|
+
elsif was_updated?(object)
|
38
|
+
attributes = object.versioned_changes.keys
|
39
|
+
"Changed #{attributes.to_sentence}"
|
40
|
+
|
41
|
+
else
|
42
|
+
"Generated Version"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
def was_created?(object)
|
48
|
+
object.persisted? && object.id_changed?
|
49
|
+
end
|
50
|
+
|
51
|
+
def was_updated?(object)
|
52
|
+
object.persisted? && !object.id_changed?
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
# The attribute diffs for this version
|
58
|
+
def attribute_diffs
|
59
|
+
@attribute_diffs ||= begin
|
60
|
+
changes = self.object_changes.dup
|
61
|
+
attribute_diffs = {}
|
62
|
+
|
63
|
+
# Compare each of object_b's attributes to object_a's attributes
|
64
|
+
# And if there is a difference, add it to the Diff
|
65
|
+
changes.each do |attribute, values|
|
66
|
+
# values is [previous_value, new_value]
|
67
|
+
diff = Diffy::Diff.new(values[0].to_s, values[1].to_s)
|
68
|
+
|
69
|
+
if diff.string1 != diff.string2
|
70
|
+
attribute_diffs[attribute] = diff
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
attribute_diffs
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# A simple title for this version.
|
79
|
+
# Example: "Article #125 v6"
|
80
|
+
def title
|
81
|
+
"#{self.versioned.class.name.titleize} " \
|
82
|
+
"##{self.versioned.id} v#{self.version_number}"
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def increment_version_number
|
89
|
+
latest_version = self.versioned.versions.order("version_number").last
|
90
|
+
self.version_number = latest_version.try(:version_number).to_i + 1
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
|
3
|
+
module Secretary
|
4
|
+
class InstallGenerator < Rails::Generators::Base
|
5
|
+
include Rails::Generators::Migration
|
6
|
+
|
7
|
+
if ActiveRecord::VERSION::MAJOR < 4
|
8
|
+
require 'rails/generators/active_record/migration'
|
9
|
+
extend ActiveRecord::Generators::Migration
|
10
|
+
else
|
11
|
+
require 'rails/generators/active_record'
|
12
|
+
def self.next_migration_number(*args)
|
13
|
+
ActiveRecord::Generators::Base.next_migration_number(*args)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
source_root File.expand_path("../templates", __FILE__)
|
18
|
+
|
19
|
+
def copy_migration
|
20
|
+
migration_template "versions_migration.rb", "db/migrate/create_versions"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class SecretaryCreateVersions < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table "versions" do |t|
|
4
|
+
t.integer "version_number"
|
5
|
+
t.string "versioned_type"
|
6
|
+
t.integer "versioned_id"
|
7
|
+
t.string "user_id"
|
8
|
+
t.text "description"
|
9
|
+
t.text "object_changes"
|
10
|
+
t.datetime "created_at"
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index "versions", ["created_at"]
|
14
|
+
add_index "versions", ["user_id"]
|
15
|
+
add_index "versions", ["version_number"]
|
16
|
+
add_index "versions", ["versioned_type", "versioned_id"]
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Secretary
|
2
|
+
class Config
|
3
|
+
DEFAULTS = {
|
4
|
+
:user_class => "::User",
|
5
|
+
:ignored_attributes => ['id', 'created_at', 'updated_at']
|
6
|
+
}
|
7
|
+
|
8
|
+
|
9
|
+
attr_writer :user_class
|
10
|
+
def user_class
|
11
|
+
@user_class || DEFAULTS[:user_class]
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_writer :ignored_attributes
|
15
|
+
def ignored_attributes
|
16
|
+
@ignored_attributes || DEFAULTS[:ignored_attributes]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
class Secretary::NotVersionedError < StandardError
|
2
|
+
def initialize(klasses=nil)
|
3
|
+
@klasses = Array(klasses)
|
4
|
+
end
|
5
|
+
|
6
|
+
def message
|
7
|
+
"Can't track an association on an unversioned model " \
|
8
|
+
"(#{@klasses.join(", ")}) Did you declare `has_secretary` first?"
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Secretary
|
2
|
+
module HasSecretary
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
module ClassMethods
|
6
|
+
# Check if a class is versioned
|
7
|
+
def has_secretary?
|
8
|
+
!!@_has_secretary
|
9
|
+
end
|
10
|
+
|
11
|
+
# Apply to any class that should be versioned
|
12
|
+
def has_secretary
|
13
|
+
@_has_secretary = true
|
14
|
+
Secretary.versioned_models.push self.name
|
15
|
+
|
16
|
+
has_many :versions,
|
17
|
+
:class_name => "Secretary::Version",
|
18
|
+
:as => :versioned,
|
19
|
+
:dependent => :destroy
|
20
|
+
|
21
|
+
attr_accessor :logged_user_id
|
22
|
+
|
23
|
+
after_save :generate_version, if: -> { self.changed? }
|
24
|
+
after_commit :clear_custom_changes
|
25
|
+
|
26
|
+
send :include, InstanceMethodsOnActivation
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
module InstanceMethodsOnActivation
|
32
|
+
# Generate a version for this object.
|
33
|
+
def generate_version
|
34
|
+
Version.generate(self)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Use Rails built-in Dirty attributions to get
|
38
|
+
# the easy ones. By the time we're generating
|
39
|
+
# this version, this hash could already
|
40
|
+
# exist with some custom changes.
|
41
|
+
def changes
|
42
|
+
self.custom_changes.reverse_merge super
|
43
|
+
end
|
44
|
+
|
45
|
+
# Use Rails' `changed?`, plus check our own custom changes
|
46
|
+
# to see if an object has been modified.
|
47
|
+
def changed?
|
48
|
+
super || custom_changes.present?
|
49
|
+
end
|
50
|
+
|
51
|
+
# Similar to ActiveModel::Dirty#changes, but lets us
|
52
|
+
# pass in some custom changes (such as associations)
|
53
|
+
# which wouldn't be picked up by the built-in method.
|
54
|
+
#
|
55
|
+
# This method should only be used for adding custom changes
|
56
|
+
# to the changes hash. For storing and comparing and whatnot,
|
57
|
+
# use #changes as usual.
|
58
|
+
#
|
59
|
+
# This method basically exists just to get around the behavior
|
60
|
+
# of #changes (since it sends the attribute message to the
|
61
|
+
# object, which we don't always want, for associations for
|
62
|
+
# example).
|
63
|
+
def custom_changes
|
64
|
+
@custom_changes ||= HashWithIndifferentAccess.new
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def clear_custom_changes
|
71
|
+
self.custom_changes.clear
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
module Secretary
|
2
|
+
module TracksAssociation
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
module ClassMethods
|
6
|
+
# Track the associations passed-in
|
7
|
+
# This will make sure that when you change the association,
|
8
|
+
# the saved record will get a new version, with association
|
9
|
+
# diffs and everything.
|
10
|
+
#
|
11
|
+
# Example:
|
12
|
+
#
|
13
|
+
# has_secretary
|
14
|
+
#
|
15
|
+
# has_many :bylines,
|
16
|
+
# :as => :content,
|
17
|
+
# :class_name => "ContentByline",
|
18
|
+
# :dependent => :destroy
|
19
|
+
#
|
20
|
+
# tracks_association :bylines
|
21
|
+
#
|
22
|
+
# Forcing the changes into the custom_changes allows us
|
23
|
+
# to keep track of dirty associations, so that checking stuff
|
24
|
+
# like `changed?` will work.
|
25
|
+
#
|
26
|
+
# If you want to control when an association should be left out of the
|
27
|
+
# version, define an instance method named `should_reject_#{name}?`.
|
28
|
+
# This method takes a hash of the model's attributes (so you can pass
|
29
|
+
# in, for example, form params). This also lets you easily share this
|
30
|
+
# method with `accepts_nested_attributes_for`.
|
31
|
+
#
|
32
|
+
# Example:
|
33
|
+
#
|
34
|
+
# class Person < ActiveRecord::Base
|
35
|
+
# has_secretary
|
36
|
+
# has_many :animals
|
37
|
+
# tracks_association :animals
|
38
|
+
#
|
39
|
+
# accepts_nested_attributes_for :animals,
|
40
|
+
# :reject_if => :should_reject_animals?
|
41
|
+
#
|
42
|
+
# private
|
43
|
+
#
|
44
|
+
# def should_reject_animals?(attributes)
|
45
|
+
# attributes['name'].blank?
|
46
|
+
# end
|
47
|
+
# end
|
48
|
+
def tracks_association(*associations)
|
49
|
+
if !self.has_secretary?
|
50
|
+
raise NotVersionedError, self.name
|
51
|
+
end
|
52
|
+
|
53
|
+
self.versioned_attributes += associations.map(&:to_s)
|
54
|
+
|
55
|
+
include InstanceMethodsOnActivation
|
56
|
+
|
57
|
+
associations.each do |name|
|
58
|
+
module_eval <<-EOE, __FILE__, __LINE__ + 1
|
59
|
+
def #{name}_were
|
60
|
+
@#{name}_were ||= association_was("#{name}")
|
61
|
+
end
|
62
|
+
|
63
|
+
def #{name}_changed?
|
64
|
+
association_changed?("#{name}")
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def preload_#{name}_were(object)
|
71
|
+
#{name}_were
|
72
|
+
end
|
73
|
+
|
74
|
+
def check_for_#{name}_changes
|
75
|
+
check_for_association_changes("#{name}")
|
76
|
+
end
|
77
|
+
|
78
|
+
def clear_dirty_#{name}
|
79
|
+
@#{name}_were = nil
|
80
|
+
end
|
81
|
+
EOE
|
82
|
+
|
83
|
+
before_save :"check_for_#{name}_changes"
|
84
|
+
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
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
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?
|
136
|
+
end
|
137
|
+
|
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
|
+
]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module Secretary
|
2
|
+
module VersionedAttributes
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
class << self
|
7
|
+
# Set the attributes which Secretary should keep track of.
|
8
|
+
#
|
9
|
+
# Example:
|
10
|
+
#
|
11
|
+
# class Article < ActiveRecord::Base
|
12
|
+
# self.versioned_attributes = [:id, :created_at]
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# Instead of setting `versioned_attributes` explicitly,
|
16
|
+
# you can set `unversioned_attributes` to tell Secretary
|
17
|
+
# which attributes to ignore.
|
18
|
+
#
|
19
|
+
# Note: These should be set before any `tracks_association`
|
20
|
+
# macros are called.
|
21
|
+
#
|
22
|
+
# Each takes an array of column names *as strings*.
|
23
|
+
attr_writer :versioned_attributes
|
24
|
+
|
25
|
+
def versioned_attributes
|
26
|
+
@versioned_attributes ||=
|
27
|
+
self.column_names -
|
28
|
+
Secretary.config.ignored_attributes -
|
29
|
+
unversioned_attributes
|
30
|
+
end
|
31
|
+
|
32
|
+
def unversioned_attributes=(array)
|
33
|
+
self.versioned_attributes -= array
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def unversioned_attributes
|
39
|
+
@unversioned_attributes ||= []
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
# The hash that gets serialized into the `object_changes` column.
|
46
|
+
def versioned_changes
|
47
|
+
self.changes.select { |k,_| versioned_attribute?(k) }.to_hash
|
48
|
+
end
|
49
|
+
|
50
|
+
def versioned_attributes
|
51
|
+
self.as_json(root: false).select { |k,_| versioned_attribute?(k) }.to_hash
|
52
|
+
end
|
53
|
+
|
54
|
+
def versioned_attribute?(key)
|
55
|
+
self.class.versioned_attributes.include?(key.to_s)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|