draftsman 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +3 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +5 -0
  7. data/Gemfile +3 -0
  8. data/Gemfile.lock +97 -0
  9. data/LICENSE +20 -0
  10. data/README.md +506 -0
  11. data/Rakefile +6 -0
  12. data/draftsman.gemspec +33 -0
  13. data/lib/draftsman/config.rb +13 -0
  14. data/lib/draftsman/draft.rb +289 -0
  15. data/lib/draftsman/frameworks/cucumber.rb +7 -0
  16. data/lib/draftsman/frameworks/rails.rb +58 -0
  17. data/lib/draftsman/frameworks/rspec.rb +16 -0
  18. data/lib/draftsman/frameworks/sinatra.rb +31 -0
  19. data/lib/draftsman/model.rb +428 -0
  20. data/lib/draftsman/serializers/json.rb +17 -0
  21. data/lib/draftsman/serializers/yaml.rb +17 -0
  22. data/lib/draftsman/version.rb +3 -0
  23. data/lib/draftsman.rb +101 -0
  24. data/lib/generators/draftsman/install_generator.rb +27 -0
  25. data/lib/generators/draftsman/templates/add_object_changes_column_to_drafts.rb +9 -0
  26. data/lib/generators/draftsman/templates/config/initializers/draftsman.rb +11 -0
  27. data/lib/generators/draftsman/templates/create_drafts.rb +22 -0
  28. data/spec/controllers/informants_controller_spec.rb +27 -0
  29. data/spec/controllers/users_controller_spec.rb +23 -0
  30. data/spec/controllers/whodunnits_controller_spec.rb +24 -0
  31. data/spec/draftsman_spec.rb +19 -0
  32. data/spec/dummy/Rakefile +7 -0
  33. data/spec/dummy/app/assets/images/rails.png +0 -0
  34. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  35. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  36. data/spec/dummy/app/controllers/application_controller.rb +20 -0
  37. data/spec/dummy/app/controllers/informants_controller.rb +8 -0
  38. data/spec/dummy/app/controllers/users_controller.rb +8 -0
  39. data/spec/dummy/app/controllers/whodunnits_controller.rb +8 -0
  40. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  41. data/spec/dummy/app/helpers/messages_helper.rb +2 -0
  42. data/spec/dummy/app/mailers/.gitkeep +0 -0
  43. data/spec/dummy/app/models/bastard.rb +3 -0
  44. data/spec/dummy/app/models/child.rb +4 -0
  45. data/spec/dummy/app/models/parent.rb +5 -0
  46. data/spec/dummy/app/models/trashable.rb +3 -0
  47. data/spec/dummy/app/models/vanilla.rb +3 -0
  48. data/spec/dummy/app/models/whitelister.rb +3 -0
  49. data/spec/dummy/app/views/layouts/application.html.erb +15 -0
  50. data/spec/dummy/config/application.rb +37 -0
  51. data/spec/dummy/config/boot.rb +6 -0
  52. data/spec/dummy/config/database.yml +25 -0
  53. data/spec/dummy/config/environment.rb +5 -0
  54. data/spec/dummy/config/environments/development.rb +32 -0
  55. data/spec/dummy/config/environments/production.rb +73 -0
  56. data/spec/dummy/config/environments/test.rb +39 -0
  57. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  58. data/spec/dummy/config/initializers/inflections.rb +15 -0
  59. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  60. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  61. data/spec/dummy/config/initializers/session_store.rb +8 -0
  62. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  63. data/spec/dummy/config/locales/en.yml +5 -0
  64. data/spec/dummy/config/routes.rb +6 -0
  65. data/spec/dummy/config.ru +4 -0
  66. data/spec/dummy/db/migrate/20110208155312_set_up_test_tables.rb +86 -0
  67. data/spec/dummy/db/schema.rb +106 -0
  68. data/spec/dummy/db/seeds.rb +7 -0
  69. data/spec/dummy/lib/assets/.gitkeep +0 -0
  70. data/spec/dummy/lib/tasks/.gitkeep +0 -0
  71. data/spec/dummy/log/.gitkeep +0 -0
  72. data/spec/dummy/public/404.html +26 -0
  73. data/spec/dummy/public/422.html +26 -0
  74. data/spec/dummy/public/500.html +25 -0
  75. data/spec/dummy/public/favicon.ico +0 -0
  76. data/spec/dummy/script/rails +6 -0
  77. data/spec/models/child_spec.rb +205 -0
  78. data/spec/models/draft_spec.rb +297 -0
  79. data/spec/models/parent_spec.rb +191 -0
  80. data/spec/models/trashable_spec.rb +164 -0
  81. data/spec/models/vanilla_spec.rb +201 -0
  82. data/spec/models/whitelister_spec.rb +262 -0
  83. data/spec/spec_helper.rb +52 -0
  84. metadata +304 -0
data/draftsman.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
2
+ require 'draftsman/version'
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'draftsman'
6
+ s.version = Draftsman::VERSION
7
+ s.summary = "Create draft versions of your ActiveRecord models' data."
8
+ s.description = s.summary
9
+ s.homepage = 'https://github.com/minimalorange/draftsman'
10
+ s.authors = ['Chris Peters']
11
+ s.email = 'chris@minimalorange.com'
12
+
13
+ s.files = `git ls-files`.split("\n")
14
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
15
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
16
+ s.require_paths = ['lib']
17
+
18
+ s.add_dependency 'activerecord', ['>= 3.0', '< 5.0']
19
+
20
+ s.add_development_dependency 'capybara'
21
+ s.add_development_dependency 'rake'
22
+ s.add_development_dependency 'railties', ['>= 3.0', '< 5.0']
23
+ s.add_development_dependency 'sinatra', '~> 1.0'
24
+ s.add_development_dependency 'rspec-rails'
25
+ s.add_development_dependency 'shoulda-matchers'
26
+
27
+ # JRuby support for the test ENV
28
+ if defined?(JRUBY_VERSION)
29
+ s.add_development_dependency 'activerecord-jdbcsqlite3-adapter', ['>= 1.3.0.rc1', '< 1.4']
30
+ else
31
+ s.add_development_dependency 'sqlite3', '~> 1.2'
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ require 'singleton'
2
+
3
+ module Draftsman
4
+ class Config
5
+ include Singleton
6
+ attr_accessor :serializer, :timestamp_field
7
+
8
+ def initialize
9
+ @timestamp_field = :created_at
10
+ @serializer = Draftsman::Serializers::Yaml
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,289 @@
1
+ class Draftsman::Draft < ActiveRecord::Base
2
+ # Mass assignment (for <= ActiveRecord 3.x)
3
+ if Draftsman.active_record_protected_attributes?
4
+ attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes
5
+ end
6
+
7
+ # Associations
8
+ belongs_to :item, :polymorphic => true
9
+
10
+ # Validations
11
+ validates_presence_of :event
12
+
13
+ def self.with_item_keys(item_type, item_id)
14
+ scoped :conditions => { :item_type => item_type, :item_id => item_id }
15
+ end
16
+
17
+ def self.creates
18
+ where :event => 'create'
19
+ end
20
+
21
+ def self.destroys
22
+ where :event => 'destroy'
23
+ end
24
+
25
+ def self.updates
26
+ where :event => 'update'
27
+ end
28
+
29
+ # Returns what changed in this draft. Similar to `ActiveModel::Dirty#changes`.
30
+ # Returns `nil` if your `drafts` table does not have an `object_changes` text column.
31
+ def changeset
32
+ return nil unless self.class.column_names.include? 'object_changes'
33
+
34
+ HashWithIndifferentAccess.new(Draftsman.serializer.load(object_changes)).tap do |changes|
35
+ item_type.constantize.unserialize_draft_attribute_changes(changes)
36
+ end
37
+ rescue
38
+ {}
39
+ end
40
+
41
+ # Returns whether or not this is a `create` event.
42
+ def create?
43
+ self.event == 'create'
44
+ end
45
+
46
+ # Returns whether or not this is a `destroy` event.
47
+ def destroy?
48
+ self.event == 'destroy'
49
+ end
50
+
51
+ # Returns related draft dependencies that would be along for the ride for a `publish!` action.
52
+ def draft_publication_dependencies
53
+ dependencies = []
54
+
55
+ case self.event
56
+ when 'create', 'update'
57
+ associations = self.item.class.reflect_on_all_associations(:belongs_to)
58
+
59
+ associations.each do |association|
60
+ association_class =
61
+ if association.polymorphic?
62
+ self.item.send(association.foreign_key.sub('_id', '_type')).constantize
63
+ else
64
+ association.klass
65
+ end
66
+
67
+ if association_class.draftable? && association.name != association_class.draft_association_name.to_sym
68
+ dependency = self.item.send(association.name)
69
+ dependencies << dependency.draft if dependency.present? && dependency.draft? && dependency.draft.create?
70
+ end
71
+ end
72
+ when 'destroy'
73
+ associations = self.item.class.reflect_on_all_associations(:has_one) + self.item.class.reflect_on_all_associations(:has_many)
74
+
75
+ associations.each do |association|
76
+ if association.klass.draftable?
77
+ # Reconcile different association types into an array, even if `has_one` produces a single-item
78
+ associated_dependencies =
79
+ case association.macro
80
+ when :has_one
81
+ self.item.send(association.name).present? ? [self.item.send(association.name)] : []
82
+ when :has_many
83
+ self.item.send(association.name)
84
+ end
85
+
86
+ associated_dependencies.each do |dependency|
87
+ dependencies << dependency.draft if dependency.draft?
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ dependencies
94
+ end
95
+
96
+ # Returns related draft dependencies that would be along for the ride for a `revert!` action.
97
+ def draft_reversion_dependencies
98
+ dependencies = []
99
+
100
+ case self.event
101
+ when 'create'
102
+ associations = self.item.class.reflect_on_all_associations(:has_one) + self.item.class.reflect_on_all_associations(:has_many)
103
+
104
+ associations.each do |association|
105
+ if association.klass.draftable?
106
+ # Reconcile different association types into an array, even if `has_one` produces a single-item
107
+ associated_dependencies =
108
+ case association.macro
109
+ when :has_one
110
+ self.item.send(association.name).present? ? [self.item.send(association.name)] : []
111
+ when :has_many
112
+ self.item.send(association.name)
113
+ end
114
+
115
+ associated_dependencies.each do |dependency|
116
+ dependencies << dependency.draft if dependency.draft?
117
+ end
118
+ end
119
+ end
120
+ when 'destroy'
121
+ associations = self.item.class.reflect_on_all_associations(:belongs_to)
122
+
123
+ associations.each do |association|
124
+ association_class =
125
+ if association.polymorphic?
126
+ self.item.send(association.foreign_key.sub('_id', '_type')).constantize
127
+ else
128
+ association.klass
129
+ end
130
+
131
+ if association_class.draftable? && association_class.trashable? && association.name != association_class.draft_association_name.to_sym
132
+ dependency = self.item.send(association.name)
133
+ dependencies << dependency.draft if dependency.present? && dependency.draft? && dependency.draft.destroy?
134
+ end
135
+ end
136
+ end
137
+
138
+ dependencies
139
+ end
140
+
141
+ # Publishes this draft's associated `item`, publishes its `item`'s dependencies, and destroys itself.
142
+ # - For `create` drafts, adds a value for the `published_at` timestamp on the item and destroys the draft.
143
+ # - For `update` drafts, applies the drafted changes to the item and destroys the draft.
144
+ # - For `destroy` drafts, destroys the item and the draft.
145
+ def publish!
146
+ ActiveRecord::Base.transaction do
147
+ case self.event
148
+ when 'create', 'update'
149
+ # Parents must be published too
150
+ self.draft_publication_dependencies.each { |dependency| dependency.publish! }
151
+
152
+ # Update drafts need to copy over data to main record
153
+ self.item.attributes = self.reify.attributes if self.update?
154
+
155
+ # Write `published_at` attribute
156
+ self.item.send "#{self.item.class.published_at_attribute_name}=", Time.now
157
+
158
+ # Clear out draft
159
+ self.item.send "#{self.item.class.draft_association_name}_id=", nil
160
+
161
+ # Determine which columns should be updated
162
+ only = self.item.class.draftsman_options[:only]
163
+ ignore = self.item.class.draftsman_options[:ignore]
164
+ skip = self.item.class.draftsman_options[:skip]
165
+ attributes_to_change = only.any? ? only : self.item.attribute_names
166
+ attributes_to_change = attributes_to_change - ignore + ['published_at', "#{self.item.class.draft_association_name}_id"] - skip
167
+
168
+ # Save without validations or callbacks
169
+ self.item.update_columns self.item.attributes.slice(*attributes_to_change)
170
+ self.item.reload
171
+
172
+ # Destroy draft
173
+ self.destroy
174
+ when 'destroy'
175
+ self.item.destroy
176
+ end
177
+ end
178
+ end
179
+
180
+ # Returns instance of item restored to its pre-draft state.
181
+ #
182
+ # Example usage:
183
+ #
184
+ # `@category = @category.reify if @category.draft?`
185
+ def reify
186
+ without_identity_map do
187
+ unless self.object.nil?
188
+ # This appears to be necessary if for some reason the draft's model hasn't been loaded (such as when done in the console).
189
+ require self.item_type.underscore
190
+
191
+ model = item.reload
192
+
193
+ Draftsman.serializer.load(self.object).each do |key, value|
194
+ # Skip counter_cache columns
195
+ if model.respond_to?("#{key}=") && !key.end_with?('_count')
196
+ model.send "#{key}=", value
197
+ elsif !key.end_with?('_count')
198
+ logger.warn "Attribute #{key} does not exist on #{item_type} (Draft ID: #{id})."
199
+ end
200
+ end
201
+
202
+ model.send "#{model.class.draft_association_name}=", self
203
+ model
204
+ end
205
+ end
206
+ end
207
+
208
+ # Reverts this draft.
209
+ # - For create drafts, destroys the draft and the item.
210
+ # - For update drafts, destyors the draft only.
211
+ # - For destroy drafts, destroys the draft and undoes the `trashed_at` timestamp on the item. If a draft was drafted
212
+ # for destroy, restores the draft.
213
+ def revert!
214
+ ActiveRecord::Base.transaction do
215
+ case self.event
216
+ when 'create'
217
+ self.item.destroy
218
+ self.destroy
219
+ when 'update'
220
+ self.item.class.where(:id => self.item).update_all("#{self.item.class.draft_association_name}_id".to_sym => nil)
221
+ self.destroy
222
+ when 'destroy'
223
+ # Parents must be restored too
224
+ self.draft_reversion_dependencies.each { |dependency| dependency.revert! }
225
+
226
+ # Restore previous draft if one was stashed away
227
+ if self.previous_draft.present?
228
+ prev_draft = reify_previous_draft
229
+ self.item.class.where(:id => self.item).update_all "#{self.item.class.draft_association_name}_id".to_sym => prev_draft.id,
230
+ self.item.class.trashed_at_attribute_name => nil
231
+ else
232
+ self.item.class.where(:id => self.item).update_all "#{self.item.class.draft_association_name}_id".to_sym => nil,
233
+ self.item.class.trashed_at_attribute_name => nil
234
+ end
235
+
236
+ self.destroy
237
+ end
238
+ end
239
+ end
240
+
241
+ # Returns whether or not this is an `update` event.
242
+ def update?
243
+ self.event == 'update'
244
+ end
245
+
246
+ private
247
+
248
+ # Restores previous draft and returns it.
249
+ def reify_previous_draft
250
+ draft = self.class.new
251
+
252
+ without_identity_map do
253
+ Draftsman.serializer.load(self.previous_draft).each do |key, value|
254
+ if key.to_sym != :id && draft.respond_to?("#{key}=")
255
+ draft.send "#{key}=", value
256
+ elsif key.to_sym != :id
257
+ logger.warn "Attribute #{key} does not exist on #{item_type} (Draft ID: #{self.id})."
258
+ end
259
+ end
260
+ end
261
+
262
+ draft.save!
263
+ draft
264
+ end
265
+
266
+ # Saves associated draft dependencies by reflecting `belongs_to` associations and identifying which ones are
267
+ # draftable.
268
+ #def save_draft_dependencies
269
+ # self.item.class.reflect_on_all_associations(:belongs_to).each do |association|
270
+ # associated_object = self.item.send(association.name)
271
+ #
272
+ # if associated_object.present? && associated_object.respond_to?(:draft?)
273
+ # if associated_object.reload.draft?
274
+ # Draftsman::DraftDependency.create(:draft_id => self.id, :dependency_id => associated_object.id)
275
+ # else
276
+ # Draftsman::DraftDependency.where(:draft_id => self.id, :dependency_id => associated_object.id).delete_all
277
+ # end
278
+ # end
279
+ # end
280
+ #end
281
+
282
+ def without_identity_map(&block)
283
+ if defined?(ActiveRecord::IdentityMap) && ActiveRecord::IdentityMap.respond_to?(:without)
284
+ ActiveRecord::IdentityMap.without &block
285
+ else
286
+ block.call
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,7 @@
1
+ if defined? World
2
+ # before hook for Cucumber
3
+ before do
4
+ ::Draftsman.whodunnit = nil
5
+ ::Draftsman.controller_info = {} if defined? ::Rails
6
+ end
7
+ end
@@ -0,0 +1,58 @@
1
+ module Draftsman
2
+ module Rails
3
+ module Controller
4
+
5
+ def self.included(base)
6
+ if defined?(ActionController) && base == ActionController::Base
7
+ base.before_filter :set_draftsman_whodunnit, :set_draftsman_controller_info
8
+ end
9
+ end
10
+
11
+ protected
12
+
13
+ # Returns the user who is responsible for any changes that occur.
14
+ # By default this calls `current_user` and returns the result.
15
+ #
16
+ # Override this method in your controller to call a different
17
+ # method, e.g. `current_person`, or anything you like.
18
+ def user_for_draftsman
19
+ current_user if defined?(current_user)
20
+ end
21
+
22
+ # Returns any information about the controller or request that you
23
+ # want Draftsman to store alongside any changes that occur. By
24
+ # default, this returns an empty hash.
25
+ #
26
+ # Override this method in your controller to return a hash of any
27
+ # information you need. The hash's keys must correspond to columns
28
+ # in your `drafts` table, so don't forget to add any new columns
29
+ # you need.
30
+ #
31
+ # For example:
32
+ #
33
+ # {:ip => request.remote_ip, :user_agent => request.user_agent}
34
+ #
35
+ # The columns `ip` and `user_agent` must exist in your `drafts` # table.
36
+ #
37
+ # Use the `:meta` option to `Draftsman::Model::ClassMethods.has_drafts`
38
+ # to store any extra model-level data you need.
39
+ def info_for_draftsman
40
+ {}
41
+ end
42
+
43
+ private
44
+
45
+ # Tells Draftsman who is responsible for any changes that occur.
46
+ def set_draftsman_whodunnit
47
+ ::Draftsman.whodunnit = user_for_draftsman
48
+ end
49
+
50
+ # Tells Draftsman any information from the controller you want
51
+ # to store alongside any changes that occur.
52
+ def set_draftsman_controller_info
53
+ ::Draftsman.controller_info = info_for_draftsman
54
+ end
55
+
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,16 @@
1
+ if defined? RSpec
2
+ require 'rspec/core'
3
+ require 'rspec/matchers'
4
+
5
+ RSpec.configure do |config|
6
+ config.before(:each) do
7
+ ::Draftsman.whodunnit = nil
8
+ ::Draftsman.controller_info = {} if defined?(::Rails) && defined?(::RSpec::Rails)
9
+ end
10
+ end
11
+
12
+ RSpec::Matchers.define :be_draftable do
13
+ # check to see if the model has `has_drafts` declared on it
14
+ match { |actual| actual.kind_of?(::Draftsman::Model::InstanceMethods) }
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ module Sinatra
2
+ module Draftsman
3
+
4
+ # Register this module inside your Sinatra application to gain access to controller-level methods used by Draftsman
5
+ def self.registered(app)
6
+ app.helpers Sinatra::Draftsman
7
+ app.before { set_draftsman_whodunnit }
8
+ end
9
+
10
+ protected
11
+
12
+ # Returns the user who is responsible for any changes that occur.
13
+ # By default this calls `current_user` and returns the result.
14
+ #
15
+ # Override this method in your controller to call a different
16
+ # method, e.g. `current_person`, or anything you like.
17
+ def user_for_draftsman
18
+ current_user if defined?(current_user)
19
+ end
20
+
21
+ private
22
+
23
+ # Tells Draftsman who is responsible for any changes that occur.
24
+ def set_draftsman_whodunnit
25
+ ::Draftsman.whodunnit = user_for_draftsman
26
+ end
27
+
28
+ end
29
+
30
+ register Sinatra::Draftsman if defined?(register)
31
+ end