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