journaled 2.0.0.alpha1 → 2.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +95 -25
  3. data/app/models/concerns/journaled/changes.rb +41 -0
  4. data/app/models/journaled/actor_uri_provider.rb +22 -0
  5. data/app/models/journaled/change_definition.rb +17 -1
  6. data/app/models/journaled/change_writer.rb +3 -22
  7. data/app/models/journaled/event.rb +0 -4
  8. data/config/initializers/change_protection.rb +3 -0
  9. data/lib/journaled.rb +9 -1
  10. data/lib/journaled/relation_change_protection.rb +27 -0
  11. data/lib/journaled/rspec.rb +18 -0
  12. data/lib/journaled/version.rb +1 -1
  13. data/spec/dummy/README.rdoc +28 -0
  14. data/spec/dummy/Rakefile +6 -0
  15. data/spec/dummy/bin/bundle +3 -0
  16. data/spec/dummy/bin/rails +4 -0
  17. data/spec/dummy/bin/rake +4 -0
  18. data/spec/dummy/config.ru +4 -0
  19. data/spec/dummy/config/application.rb +26 -0
  20. data/spec/dummy/config/boot.rb +5 -0
  21. data/spec/dummy/config/database.yml +21 -0
  22. data/spec/dummy/config/environment.rb +5 -0
  23. data/spec/dummy/config/environments/development.rb +37 -0
  24. data/spec/dummy/config/environments/production.rb +78 -0
  25. data/spec/dummy/config/environments/test.rb +39 -0
  26. data/spec/dummy/config/initializers/assets.rb +8 -0
  27. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  28. data/spec/dummy/config/initializers/cookies_serializer.rb +3 -0
  29. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  30. data/spec/dummy/config/initializers/inflections.rb +16 -0
  31. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  32. data/spec/dummy/config/initializers/session_store.rb +3 -0
  33. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  34. data/spec/dummy/config/locales/en.yml +23 -0
  35. data/spec/dummy/config/routes.rb +56 -0
  36. data/spec/dummy/config/secrets.yml +22 -0
  37. data/spec/dummy/db/migrate/20180606205114_create_delayed_jobs.rb +18 -0
  38. data/spec/dummy/db/schema.rb +31 -0
  39. data/spec/dummy/log/development.log +34 -0
  40. data/spec/dummy/log/test.log +34540 -0
  41. data/spec/dummy/public/404.html +67 -0
  42. data/spec/dummy/public/422.html +67 -0
  43. data/spec/dummy/public/500.html +66 -0
  44. data/spec/dummy/public/favicon.ico +0 -0
  45. data/spec/lib/journaled_spec.rb +51 -0
  46. data/spec/models/concerns/journaled/actor_spec.rb +46 -0
  47. data/spec/models/concerns/journaled/changes_spec.rb +94 -0
  48. data/spec/models/database_change_protection_spec.rb +106 -0
  49. data/spec/models/journaled/actor_uri_provider_spec.rb +41 -0
  50. data/spec/models/journaled/change_writer_spec.rb +276 -0
  51. data/spec/models/journaled/delivery_spec.rb +156 -0
  52. data/spec/models/journaled/event_spec.rb +145 -0
  53. data/spec/models/journaled/json_schema_model/validator_spec.rb +133 -0
  54. data/spec/models/journaled/writer_spec.rb +129 -0
  55. data/spec/rails_helper.rb +20 -0
  56. data/spec/spec_helper.rb +22 -0
  57. data/spec/support/delayed_job_spec_helper.rb +11 -0
  58. data/spec/support/environment_spec_helper.rb +16 -0
  59. metadata +113 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4ee54a77497db5d43b3932d6015b18b6b2c4ae45
4
- data.tar.gz: 6fc6bed161faf09676c7578531eb563a47ee9e6b
3
+ metadata.gz: 205035e968787393ef42062f36bdb6068e48a962
4
+ data.tar.gz: bd6db1eec4f70ed2311e099d942014f9dd10c073
5
5
  SHA512:
6
- metadata.gz: 030301523134d5a36c15de4e7096917c9d948c00c1514c41c9e82ff0184f358f9c346786e9f1757d229c0050073f996b03ff2bac038a7a773a9f9c02bd56bd5a
7
- data.tar.gz: a6d3c21d82902e96f3486b3637365b20d0803a11ece98260121bc80b73555bf823b3eaf6731ba82daa8c83e6044f815793e4c5d73c996d8606eed7f0dec85f5a
6
+ metadata.gz: 4f482a501e506d57a38345a985485846f842a714c16a0e4af90b1a130751af5ec482759ed1a72aada99db5435adf5fbea3f137caa3e7e341f245ebbd4a5bb0d3
7
+ data.tar.gz: 46644b2d30fc64f0c52a810203940a8bffc6b8cd854635199582e356bbeb61c2749f87aa7046f8fa44d33011e384ec31983bd0be6537735d69d118777b84ec5f
data/README.md CHANGED
@@ -26,37 +26,44 @@ if you haven't already.
26
26
 
27
27
  2. To integrate Journaled into your application, simply include the gem in your
28
28
  app's Gemfile.
29
+
30
+ ```ruby
31
+ gem 'journaled'
29
32
  ```
30
- gem 'journaled', https_github: 'Betterment/journaled'
33
+
34
+ If you use rspec, add the following to your rails helper:
35
+
36
+ ```ruby
37
+ # spec/rails_helper.rb
38
+
39
+ # ... your other requires
40
+ require 'journaled/rspec'
31
41
  ```
32
- 3. You will also need to define the following environment variables to allow Journaled to publish events to your AWS Kinesis event stream:
33
42
 
34
- * `JOURNALED_STREAM_NAME`
43
+ 3. You will also need to define the following environment variables to allow Journaled to publish events to your AWS Kinesis event stream:
35
44
 
36
- Special case: if your `Journaled::Event` objects override the
37
- `#journaled_app_name` method to a non-nil value e.g. `my_app`, you will
38
- instead need to provide a corresponding
39
- `[upcased_app_name]_JOURNALED_STREAM_NAME` variable for each distinct
40
- value, e.g. `MY_APP_JOURNALED_STREAM_NAME`. You can provide a default value
41
- for all `Journaled::Event`s in an initializer like this:
45
+ * `JOURNALED_STREAM_NAME`
42
46
 
43
- ```ruby
44
- Journaled.default_app_name = 'my_app'
45
- ```
47
+ Special case: if your `Journaled::Event` objects override the
48
+ `#journaled_app_name` method to a non-nil value e.g. `my_app`, you will
49
+ instead need to provide a corresponding
50
+ `[upcased_app_name]_JOURNALED_STREAM_NAME` variable for each distinct
51
+ value, e.g. `MY_APP_JOURNALED_STREAM_NAME`. You can provide a default value
52
+ for all `Journaled::Event`s in an initializer like this:
46
53
 
47
- You may optionally define the following ENV vars to specify AWS
48
- credentials outside of the locations that the AWS SDK normally looks:
54
+ ```ruby
55
+ Journaled.default_app_name = 'my_app'
56
+ ```
49
57
 
50
- * `RUBY_AWS_ACCESS_KEY_ID`
51
- * `RUBY_AWS_SECRET_ACCESS_KEY`
58
+ You may optionally define the following ENV vars to specify AWS
59
+ credentials outside of the locations that the AWS SDK normally looks:
52
60
 
53
- You may also specify the region to target your AWS stream by setting
54
- `AWS_DEFAULT_REGION`. If you don't specify, Journaled will default to
55
- `us-east-1`.
61
+ * `RUBY_AWS_ACCESS_KEY_ID`
62
+ * `RUBY_AWS_SECRET_ACCESS_KEY`
56
63
 
57
- Journaled::Event provides a `commit_hash` method which you may journal
58
- if you like. If you choose to use it, you must provide a `GIT_COMMIT`
59
- environment variable.
64
+ You may also specify the region to target your AWS stream by setting
65
+ `AWS_DEFAULT_REGION`. If you don't specify, Journaled will default to
66
+ `us-east-1`.
60
67
 
61
68
  ## Usage
62
69
 
@@ -66,7 +73,7 @@ Out of the box, `Journaled` provides an event type and ActiveRecord
66
73
  mix-in for durably journaling changes to your model, implemented via
67
74
  ActiveRecord hooks. Use it like so:
68
75
 
69
- ```
76
+ ```ruby
70
77
  class User < ApplicationRecord
71
78
  include Journaled::Changes
72
79
 
@@ -76,7 +83,7 @@ end
76
83
 
77
84
  Add the following to your controller base class for attribution:
78
85
 
79
- ```
86
+ ```ruby
80
87
  class ApplicationController < ActionController::Base
81
88
  include Journaled::Actor
82
89
 
@@ -101,10 +108,30 @@ record is created or destroyed, an event will be sent to Kinesis with the follow
101
108
  your `journal_changes_to` declaration (e.g. `identity_change`)
102
109
  * `changes` - a serialized JSON object representing the latest values
103
110
  of any new or changed attributes from the specified set (e.g.
104
- `{"email":"mynewemail@example.com"}`)
111
+ `{"email":"mynewemail@example.com"}`). Upon destroy, all
112
+ specified attributes will be serialized as they were last stored.
105
113
  * `actor` - a string (usually a rails global_id) representing who
106
114
  performed the action.
107
115
 
116
+ Callback-bypassing database methods like `update_all`, `delete_all`,
117
+ `update_columns` and `delete` are intercepted and will require an
118
+ additional `force: true` argument if they would interfere with change
119
+ journaling. Note that the less-frequently-used methods `toggle`,
120
+ `increment*`, `decrement*`, and `update_counters` are not intercepted at
121
+ this time.
122
+
123
+ #### Testing
124
+
125
+ If you use RSpec (and have required `journaled/rspec` in your
126
+ `spec/rails_helper.rb`), you can regression-protect important journaling
127
+ config with the `journal_changes_to` matcher:
128
+
129
+ ```ruby
130
+ it "journals exactly these things or there will be heck to pay" do
131
+ expect(User).to journal_changes_to(:email, :first_name, :last_name, as: :identity_change)
132
+ end
133
+ ```
134
+
108
135
  ### Custom Journaling
109
136
 
110
137
  For every custom implementation of journaling in your application, define the JSON schema for the attributes in your event.
@@ -189,6 +216,49 @@ An event like the following will be journaled to kinesis:
189
216
  }
190
217
  ```
191
218
 
219
+ ### Helper methods for custom events
220
+
221
+ Journaled provides a couple helper methods that may be useful in your
222
+ custom events. You can add whichever you need your event types like
223
+ this:
224
+
225
+ ```ruby
226
+ # my_event.rb
227
+ class MyEvent
228
+ include Journaled::Event
229
+
230
+ journal_attributes :commit_hash, :actor_uri # ... etc, etc
231
+
232
+ def commit_hash
233
+ Journaled.commit_hash
234
+ end
235
+
236
+ def actor_uri
237
+ Journaled.actor_uri
238
+ end
239
+
240
+ # ... etc, etc
241
+ end
242
+ ```
243
+
244
+ #### `Journaled.commit_hash`
245
+
246
+ If you choose to use it, you must provide a `GIT_COMMIT` environment
247
+ variable. `Journaled.commit_hash` will fail if it is undefined.
248
+
249
+ #### `Journaled.actor_uri`
250
+
251
+ Returns one of the following in order of preference:
252
+
253
+ * The current controller-defined `journaled_actor`'s GlobalID, if
254
+ set
255
+ * A string of the form `gid://[app_name]/[os_username]` if performed on
256
+ the command line
257
+ * a string of the form `gid://[app_name]` as a fallback
258
+
259
+ In order for this to be most useful, you must configure your controller
260
+ as described in [Change Journaling](#change-journaling) above.
261
+
192
262
  ## Future improvements & issue tracking
193
263
  Suggestions for enhancements to this engine are currently being tracked via Github Issues. Please feel free to open an
194
264
  issue for a desired feature, as well as for any observed bugs.
@@ -3,7 +3,9 @@ module Journaled::Changes
3
3
 
4
4
  included do
5
5
  cattr_accessor :_journaled_change_definitions
6
+ cattr_accessor :journaled_attribute_names
6
7
  self._journaled_change_definitions = []
8
+ self.journaled_attribute_names = []
7
9
 
8
10
  after_create do
9
11
  self.class._journaled_change_definitions.each do |definition|
@@ -24,6 +26,32 @@ module Journaled::Changes
24
26
  end
25
27
  end
26
28
 
29
+ def delete(force: false)
30
+ if force || self.class.journaled_attribute_names.empty?
31
+ super()
32
+ else
33
+ raise(<<~ERROR)
34
+ #delete aborted by Journaled::Changes.
35
+
36
+ Invoke #delete(force: true) to override and skip journaling.
37
+ ERROR
38
+ end
39
+ end
40
+
41
+ def update_columns(attributes, force: false)
42
+ unless force || self.class.journaled_attribute_names.empty?
43
+ conflicting_journaled_attribute_names = self.class.journaled_attribute_names & attributes.keys.map(&:to_sym)
44
+ raise(<<~ERROR) if conflicting_journaled_attribute_names.present?
45
+ #update_columns aborted by Journaled::Changes due to journaled attributes:
46
+
47
+ #{conflicting_journaled_attribute_names.join(', ')}
48
+
49
+ Invoke #update_columns with additional arg `{ force: true }` to override and skip journaling.
50
+ ERROR
51
+ end
52
+ super(attributes)
53
+ end
54
+
27
55
  class_methods do
28
56
  def journal_changes_to(*attribute_names, as:) # rubocop:disable Naming/UncommunicativeMethodParamName
29
57
  if attribute_names.empty? || attribute_names.any? { |n| !n.is_a?(Symbol) }
@@ -33,6 +61,19 @@ module Journaled::Changes
33
61
  raise "as: must be a symbol" unless as.is_a?(Symbol)
34
62
 
35
63
  _journaled_change_definitions << Journaled::ChangeDefinition.new(attribute_names: attribute_names, logical_operation: as)
64
+ journaled_attribute_names.concat(attribute_names)
65
+ end
66
+
67
+ def delete(id_or_array, force: false)
68
+ if force || journaled_attribute_names.empty?
69
+ where(primary_key => id_or_array).delete_all(force: true)
70
+ else
71
+ raise(<<~ERROR)
72
+ #delete aborted by Journaled::Changes.
73
+
74
+ Invoke #delete(id_or_array, force: true) to override and skip journaling.
75
+ ERROR
76
+ end
36
77
  end
37
78
  end
38
79
  end
@@ -0,0 +1,22 @@
1
+ class Journaled::ActorUriProvider
2
+ include Singleton
3
+
4
+ def actor_uri
5
+ actor_global_id_uri || fallback_global_id_uri
6
+ end
7
+
8
+ private
9
+
10
+ def actor_global_id_uri
11
+ actor = RequestStore.store[:journaled_actor_proc]&.call
12
+ actor.to_global_id.to_s if actor
13
+ end
14
+
15
+ def fallback_global_id_uri
16
+ if defined?(::Rails::Console) || File.basename($PROGRAM_NAME) == "rake"
17
+ "gid://local/#{Etc.getlogin}"
18
+ else
19
+ "gid://#{Rails.application.config.global_id.app}"
20
+ end
21
+ end
22
+ end
@@ -2,7 +2,23 @@ class Journaled::ChangeDefinition
2
2
  attr_reader :attribute_names, :logical_operation
3
3
 
4
4
  def initialize(attribute_names:, logical_operation:)
5
- @attribute_names = attribute_names
5
+ @attribute_names = attribute_names.map(&:to_s)
6
6
  @logical_operation = logical_operation
7
+ @validated = false
8
+ end
9
+
10
+ def validated?
11
+ @validated
12
+ end
13
+
14
+ def validate!(model)
15
+ nonexistent_attribute_names = attribute_names - model.class.attribute_names
16
+ raise <<~ERROR if nonexistent_attribute_names.present?
17
+ Unable to persist #{model} because `journal_changes_to, as: #{logical_operation.inspect}`
18
+ includes nonexistant attributes:
19
+
20
+ #{nonexistent_attribute_names.join(', ')}
21
+ ERROR
22
+ @validated = true
7
23
  end
8
24
  end
@@ -1,14 +1,11 @@
1
1
  class Journaled::ChangeWriter
2
2
  attr_reader :model, :change_definition
3
- delegate :logical_operation, to: :change_definition
3
+ delegate :attribute_names, :logical_operation, to: :change_definition
4
4
 
5
5
  def initialize(model:, change_definition:)
6
6
  @model = model
7
7
  @change_definition = change_definition
8
- end
9
-
10
- def attribute_names
11
- @attribute_names ||= change_definition.attribute_names.map(&:to_s)
8
+ change_definition.validate!(model) unless change_definition.validated?
12
9
  end
13
10
 
14
11
  def create
@@ -48,27 +45,11 @@ class Journaled::ChangeWriter
48
45
  end
49
46
 
50
47
  def actor_uri
51
- actor_global_id_uri || fallback_global_id_uri
48
+ @actor_uri ||= Journaled.actor_uri
52
49
  end
53
50
 
54
51
  private
55
52
 
56
- def actor_global_id_uri
57
- actor.to_global_id.to_s if actor
58
- end
59
-
60
- def actor
61
- @actor ||= RequestStore.store[:journaled_actor_proc]&.call
62
- end
63
-
64
- def fallback_global_id_uri
65
- if defined?(::Rails::Console) || File.basename($PROGRAM_NAME) == "rake"
66
- "gid://local/#{Etc.getlogin}"
67
- else
68
- "gid://#{Rails.application.config.global_id.app}"
69
- end
70
- end
71
-
72
53
  def pluck_changed_values(change_hash, index:)
73
54
  change_hash.each_with_object({}) do |(k, v), result|
74
55
  result[k] = v[index]
@@ -19,10 +19,6 @@ module Journaled::Event
19
19
  @created_at ||= Time.zone.now
20
20
  end
21
21
 
22
- def commit_hash
23
- @commit_hash ||= ENV.fetch('GIT_COMMIT')
24
- end
25
-
26
22
  # Event metadata and configuration (not serialized)
27
23
 
28
24
  def journaled_schema_name
@@ -0,0 +1,3 @@
1
+ require 'journaled/relation_change_protection'
2
+
3
+ ActiveRecord::Relation.class_eval { prepend Journaled::RelationChangeProtection }
@@ -20,5 +20,13 @@ module Journaled
20
20
  @schema_providers ||= [Journaled::Engine, Rails]
21
21
  end
22
22
 
23
- module_function :development_or_test?, :enabled?, :schema_providers
23
+ def commit_hash
24
+ ENV.fetch('GIT_COMMIT')
25
+ end
26
+
27
+ def actor_uri
28
+ Journaled::ActorUriProvider.instance.actor_uri
29
+ end
30
+
31
+ module_function :development_or_test?, :enabled?, :schema_providers, :commit_hash, :actor_uri
24
32
  end
@@ -0,0 +1,27 @@
1
+ module Journaled::RelationChangeProtection
2
+ def update_all(updates, force: false)
3
+ unless force || !@klass.respond_to?(:journaled_attribute_names) || @klass.journaled_attribute_names.empty?
4
+ conflicting_journaled_attribute_names = @klass.journaled_attribute_names & updates.keys.map(&:to_sym)
5
+ raise(<<~ERROR) if conflicting_journaled_attribute_names.present?
6
+ #update_all aborted by Journaled::Changes due to journaled attributes:
7
+
8
+ #{conflicting_journaled_attribute_names.join(', ')}
9
+
10
+ Invoke #update_all with additional arg `{ force: true }` to override and skip journaling.
11
+ ERROR
12
+ end
13
+ super(updates)
14
+ end
15
+
16
+ def delete_all(force: false)
17
+ if force || !@klass.respond_to?(:journaled_attribute_names) || @klass.journaled_attribute_names.empty?
18
+ super()
19
+ else
20
+ raise(<<~ERROR)
21
+ #delete_all aborted by Journaled::Changes.
22
+
23
+ Invoke #delete_all(force: true) to override and skip journaling.
24
+ ERROR
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ require 'rspec/expectations'
2
+
3
+ RSpec::Matchers.define :journal_changes_to do |*attribute_names, as:|
4
+ match do |model_class|
5
+ model_class._journaled_change_definitions.any? do |change_definition|
6
+ change_definition.logical_operation == as &&
7
+ attribute_names.map(&:to_s).sort == change_definition.attribute_names.sort
8
+ end
9
+ end
10
+
11
+ failure_message do |model_class|
12
+ "expected #{model_class} to journal changes to #{attribute_names.map(&:inspect).join(', ')} as #{as.inspect}"
13
+ end
14
+
15
+ failure_message_when_negated do |model_class|
16
+ "expected #{model_class} not to journal changes to #{attribute_names.map(&:inspect).join(', ')} as #{as.inspect}"
17
+ end
18
+ end
@@ -1,3 +1,3 @@
1
1
  module Journaled
2
- VERSION = "2.0.0.alpha1".freeze
2
+ VERSION = "2.0.0.rc1".freeze
3
3
  end
@@ -0,0 +1,28 @@
1
+ == README
2
+
3
+ This README would normally document whatever steps are necessary to get the
4
+ application up and running.
5
+
6
+ Things you may want to cover:
7
+
8
+ * Ruby version
9
+
10
+ * System dependencies
11
+
12
+ * Configuration
13
+
14
+ * Database creation
15
+
16
+ * Database initialization
17
+
18
+ * How to run the test suite
19
+
20
+ * Services (job queues, cache servers, search engines, etc.)
21
+
22
+ * Deployment instructions
23
+
24
+ * ...
25
+
26
+
27
+ Please feel free to use a different markup language if you do not plan to run
28
+ <tt>rake doc:app</tt>.