draftsman 0.1.0

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.
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