journaled 4.1.0 → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -46
  3. data/Rakefile +10 -24
  4. data/app/jobs/journaled/delivery_job.rb +17 -28
  5. data/app/models/concerns/journaled/changes.rb +5 -5
  6. data/app/models/journaled/change.rb +12 -12
  7. data/app/models/journaled/change_writer.rb +3 -2
  8. data/app/models/journaled/event.rb +1 -1
  9. data/app/models/journaled/writer.rb +32 -15
  10. data/lib/journaled/connection.rb +48 -0
  11. data/lib/journaled/engine.rb +5 -0
  12. data/lib/journaled/errors.rb +3 -0
  13. data/lib/journaled/relation_change_protection.rb +11 -10
  14. data/lib/journaled/rspec.rb +86 -0
  15. data/lib/journaled/transaction_ext.rb +31 -0
  16. data/lib/journaled/version.rb +1 -1
  17. data/lib/journaled.rb +17 -13
  18. metadata +54 -97
  19. data/spec/dummy/README.rdoc +0 -28
  20. data/spec/dummy/Rakefile +0 -6
  21. data/spec/dummy/bin/bundle +0 -3
  22. data/spec/dummy/bin/rails +0 -4
  23. data/spec/dummy/bin/rake +0 -4
  24. data/spec/dummy/config/application.rb +0 -25
  25. data/spec/dummy/config/boot.rb +0 -5
  26. data/spec/dummy/config/database.yml +0 -6
  27. data/spec/dummy/config/environment.rb +0 -5
  28. data/spec/dummy/config/environments/development.rb +0 -24
  29. data/spec/dummy/config/environments/test.rb +0 -37
  30. data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
  31. data/spec/dummy/config/initializers/cookies_serializer.rb +0 -3
  32. data/spec/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  33. data/spec/dummy/config/initializers/inflections.rb +0 -16
  34. data/spec/dummy/config/initializers/mime_types.rb +0 -4
  35. data/spec/dummy/config/initializers/session_store.rb +0 -3
  36. data/spec/dummy/config/initializers/wrap_parameters.rb +0 -14
  37. data/spec/dummy/config/locales/en.yml +0 -23
  38. data/spec/dummy/config/routes.rb +0 -56
  39. data/spec/dummy/config/secrets.yml +0 -22
  40. data/spec/dummy/config.ru +0 -4
  41. data/spec/dummy/db/schema.rb +0 -18
  42. data/spec/dummy/public/404.html +0 -67
  43. data/spec/dummy/public/422.html +0 -67
  44. data/spec/dummy/public/500.html +0 -66
  45. data/spec/dummy/public/favicon.ico +0 -0
  46. data/spec/jobs/journaled/delivery_job_spec.rb +0 -276
  47. data/spec/lib/journaled_spec.rb +0 -91
  48. data/spec/models/concerns/journaled/actor_spec.rb +0 -47
  49. data/spec/models/concerns/journaled/changes_spec.rb +0 -106
  50. data/spec/models/database_change_protection_spec.rb +0 -109
  51. data/spec/models/journaled/actor_uri_provider_spec.rb +0 -42
  52. data/spec/models/journaled/change_writer_spec.rb +0 -281
  53. data/spec/models/journaled/event_spec.rb +0 -236
  54. data/spec/models/journaled/json_schema_model/validator_spec.rb +0 -133
  55. data/spec/models/journaled/writer_spec.rb +0 -174
  56. data/spec/rails_helper.rb +0 -19
  57. data/spec/spec_helper.rb +0 -20
  58. data/spec/support/environment_spec_helper.rb +0 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88602263644ea51719a18c5ee3e719d2c13a9e69049e1f20a52a44edc38a0835
4
- data.tar.gz: c7cafdb9f305e51c3590d09815d5d6f909db496866c774ab442ffaa334be927c
3
+ metadata.gz: ac63d8988a5cbbd63340c019b82642a039a9dfee81f46d83b15e5a71d83e9cb7
4
+ data.tar.gz: ae809e4b05901d6eb73eb0196119999be2b0fd414c3af3c4bb30d280216098cf
5
5
  SHA512:
6
- metadata.gz: 6bf229efd53882ed50931c964c1564b01603fa22ea2083b8a6039d0a0ed972d2cdc7bfc5913a9e9539d7dd023294c2a226963e2e2a526bf857926afe3e0c76c0
7
- data.tar.gz: cf7b28c58c40c9ee8348df60095a7b544c0a8b0ea448ffa20021aceda61ae2feda61110ef9685756d4bac7d3d887e12a181f1840d435b2d2f85aeba5d8a52e45
6
+ metadata.gz: 72bcbe0ae43717280eb8a0b3383672cc87762e4c2b9b082778f1beac249004ef90a41e32e6fbabd693377a4d0af6cf6fc78df8a2b8ff44067276dd502cece63a
7
+ data.tar.gz: b21401ba2cf6155a25f50f1c58c634e3036754ec69b8d7f287c86408f69819aed5cfac1a43d55636a2be461545096b1510318a6ce665bf47595f30c8b90bceb9
data/README.md CHANGED
@@ -179,52 +179,6 @@ journaling. Note that the less-frequently-used methods `toggle`,
179
179
  `increment*`, `decrement*`, and `update_counters` are not intercepted at
180
180
  this time.
181
181
 
182
- ### Tagged Events
183
-
184
- Events may be optionally marked as "tagged." This will add a `tags` field, intended for tracing and
185
- auditing purposes.
186
-
187
- ```ruby
188
- class MyEvent
189
- include Journaled::Event
190
-
191
- journal_attributes :attr_1, :attr_2, tagged: true
192
- end
193
- ```
194
-
195
- You may then use `Journaled.tag!` and `Journaled.tagged` inside of your
196
- `ApplicationController` and `ApplicationJob` classes (or anywhere else!) to tag
197
- all events with request and job metadata:
198
-
199
- ```ruby
200
- class ApplicationController < ActionController::Base
201
- before_action do
202
- Journaled.tag!(request_id: request.request_id, current_user_id: current_user&.id)
203
- end
204
- end
205
-
206
- class ApplicationJob < ActiveJob::Base
207
- around_perform do |job, perform|
208
- Journaled.tagged(job_id: job.id) { perform.call }
209
- end
210
- end
211
- ```
212
-
213
- This feature relies on `ActiveSupport::CurrentAttributes` under the hood, so these tags are local to
214
- the current thread, and will be cleared at the end of each request request/job.
215
-
216
- #### Testing
217
-
218
- If you use RSpec (and have required `journaled/rspec` in your
219
- `spec/rails_helper.rb`), you can regression-protect important journaling
220
- config with the `journal_changes_to` matcher:
221
-
222
- ```ruby
223
- it "journals exactly these things or there will be heck to pay" do
224
- expect(User).to journal_changes_to(:email, :first_name, :last_name, as: :identity_change)
225
- end
226
- ```
227
-
228
182
  ### Custom Journaling
229
183
 
230
184
  For every custom implementation of journaling in your application, define the JSON schema for the attributes in your event.
@@ -309,6 +263,40 @@ An event like the following will be journaled to kinesis:
309
263
  }
310
264
  ```
311
265
 
266
+ ### Tagged Events
267
+
268
+ Events may be optionally marked as "tagged." This will add a `tags` field, intended for tracing and
269
+ auditing purposes.
270
+
271
+ ```ruby
272
+ class MyEvent
273
+ include Journaled::Event
274
+
275
+ journal_attributes :attr_1, :attr_2, tagged: true
276
+ end
277
+ ```
278
+
279
+ You may then use `Journaled.tag!` and `Journaled.tagged` inside of your
280
+ `ApplicationController` and `ApplicationJob` classes (or anywhere else!) to tag
281
+ all events with request and job metadata:
282
+
283
+ ```ruby
284
+ class ApplicationController < ActionController::Base
285
+ before_action do
286
+ Journaled.tag!(request_id: request.request_id, current_user_id: current_user&.id)
287
+ end
288
+ end
289
+
290
+ class ApplicationJob < ActiveJob::Base
291
+ around_perform do |job, perform|
292
+ Journaled.tagged(job_id: job.id) { perform.call }
293
+ end
294
+ end
295
+ ```
296
+
297
+ This feature relies on `ActiveSupport::CurrentAttributes` under the hood, so these tags are local to
298
+ the current thread, and will be cleared at the end of each request request/job.
299
+
312
300
  ### Helper methods for custom events
313
301
 
314
302
  Journaled provides a couple helper methods that may be useful in your
@@ -352,6 +340,102 @@ Returns one of the following in order of preference:
352
340
  In order for this to be most useful, you must configure your controller
353
341
  as described in [Change Journaling](#change-journaling) above.
354
342
 
343
+ ### Testing
344
+
345
+ If you use RSpec, you can test for journaling behaviors with the
346
+ `journal_event(s)_including` and `journal_changes_to` matchers. First, make
347
+ sure to require `journaled/rspec` in your spec setup (e.g.
348
+ `spec/rails_helper.rb`):
349
+
350
+ ```ruby
351
+ require 'journaled/rspec'
352
+ ```
353
+
354
+ #### Checking for specific events
355
+
356
+ The `journal_event_including` and `journal_events_including` matchers allow you
357
+ to check for one or more matching event being journaled:
358
+
359
+ ```ruby
360
+ expect { my_code }
361
+ .to journal_event_including(name: 'foo')
362
+ expect { my_code }
363
+ .to journal_events_including({ name: 'foo', value: 1 }, { name: 'foo', value: 2 })
364
+ ```
365
+
366
+ This will only perform matches on the specified fields (and will not match one
367
+ way or the other against unspecified fields). These matchers will also ignore
368
+ any extraneous events that are not positively matched (as they may be unrelated
369
+ to behavior under test).
370
+
371
+ When writing tests, pairing every positive assertion with a negative assertion
372
+ is a good practice, and so negative matching is also supported (via both
373
+ `.not_to` and `.to not_`):
374
+
375
+ ```ruby
376
+ expect { my_code }
377
+ .not_to journal_events_including({ name: 'foo' }, { name: 'bar' })
378
+ expect { my_code }
379
+ .to raise_error(SomeError)
380
+ .and not_journal_event_including(name: 'foo') # the `not_` variant can chain off of `.and`
381
+ ```
382
+
383
+ Several chainable modifiers are also available:
384
+
385
+ ```ruby
386
+ expect { my_code }.to journal_event_including(name: 'foo')
387
+ .with_schema_name('my_event_schema')
388
+ .with_partition_key(user.id)
389
+ .with_stream_name('my_stream_name')
390
+ .with_enqueue_opts(run_at: future_time)
391
+ .with_priority(999)
392
+ ```
393
+
394
+ All of this can be chained together to test for multiple sets of events with
395
+ multiple sets of options:
396
+
397
+ ```ruby
398
+ expect { subject.journal! }
399
+ .to journal_events_including({ name: 'event1', value: 300 }, { name: 'event2', value: 200 })
400
+ .with_priority(10)
401
+ .and journal_event_including(name: 'event3', value: 100)
402
+ .with_priority(20)
403
+ .and not_journal_event_including(name: 'other_event')
404
+ ```
405
+
406
+ #### Checking for `Journaled::Changes` declarations
407
+
408
+ The `journal_changes_to` matcher checks against the list of attributes specified
409
+ on the model. It does not actually test that an event is emitted within a given
410
+ codepath, and is instead intended to guard against accidental regressions that
411
+ may impact external consumers of these events:
412
+
413
+ ```ruby
414
+ it "journals exactly these things or there will be heck to pay" do
415
+ expect(User).to journal_changes_to(:email, :first_name, :last_name, as: :identity_change)
416
+ end
417
+ ```
418
+
419
+ ### Instrumentation
420
+
421
+ When an event is enqueued, an `ActiveSupport::Notification` titled
422
+ `journaled.event.enqueue` is emitted. Its payload will include the `:event` and
423
+ its background job `:priority`.
424
+
425
+ This can be forwarded along to your preferred monitoring solution via a Rails
426
+ initializer:
427
+
428
+ ```ruby
429
+ ActiveSupport::Notifications.subscribe('journaled.event.enqueue') do |*args|
430
+ payload = ActiveSupport::Notifications::Event.new(*args).payload
431
+ journaled_event = payload[:event]
432
+
433
+ tags = { priority: payload[:priority], event_type: journaled_event.journaled_attributes[:event_type] }
434
+
435
+ Statsd.increment('journaled.event.enqueue', tags: tags.map { |k,v| "#{k.to_s[0..64]}:#{v.to_s[0..255]}" })
436
+ end
437
+ ```
438
+
355
439
  ## Upgrades
356
440
 
357
441
  Since this gem relies on background jobs (which can remain in the queue across
@@ -360,6 +444,24 @@ gem version.
360
444
 
361
445
  As such, **we always recommend upgrading only one major version at a time.**
362
446
 
447
+ ### Upgrading from 4.3.0
448
+
449
+ Versions of Journaled prior to 5.0 would enqueue events one at a time, but 5.0
450
+ introduces a new transaction-aware feature that will bundle up all events
451
+ emitted within a transaction and enqueue them all in a single "batch" job
452
+ directly before the SQL `COMMIT` statement. This reduces the database impact of
453
+ emitting a large volume of events at once.
454
+
455
+ This feature can be disabled conditionally:
456
+
457
+ ```ruby
458
+ Journaled.transactional_batching_enabled = false
459
+ ```
460
+
461
+ Backwards compatibility has been included for background jobs enqueued by
462
+ version 4.0 and above, but **has been dropped for jobs emitted by versions prior
463
+ to 4.0**. (Again, be sure to upgrade only one major version at a time.)
464
+
363
465
  ### Upgrading from 3.1.0
364
466
 
365
467
  Versions of Journaled prior to 4.0 relied directly on environment variables for stream names, but now stream names are configured directly.
data/Rakefile CHANGED
@@ -4,37 +4,23 @@ rescue LoadError
4
4
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
5
  end
6
6
 
7
- require 'rdoc/task'
8
-
9
- RDoc::Task.new(:rdoc) do |rdoc|
10
- rdoc.rdoc_dir = 'rdoc'
11
- rdoc.title = 'Journaled'
12
- rdoc.options << '--line-numbers'
13
- rdoc.rdoc_files.include('README.rdoc')
14
- rdoc.rdoc_files.include('lib/**/*.rb')
15
- end
16
-
17
- APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
18
- load 'rails/tasks/engine.rake'
19
-
20
7
  Bundler::GemHelper.install_tasks
21
8
 
22
- if %w(development test).include? Rails.env
23
- require 'rspec/core'
24
- require 'rspec/core/rake_task'
25
- RSpec::Core::RakeTask.new
9
+ require 'rubocop/rake_task'
10
+ RuboCop::RakeTask.new
26
11
 
27
- require 'rubocop/rake_task'
28
- RuboCop::RakeTask.new
12
+ require 'rspec/core'
13
+ require 'rspec/core/rake_task'
14
+ RSpec::Core::RakeTask.new(:spec)
29
15
 
30
- task(:default).clear
16
+ def default_task
31
17
  if ENV['APPRAISAL_INITIALIZED'] || ENV['CI']
32
- task default: %i(rubocop spec)
18
+ %i(rubocop spec)
33
19
  else
34
20
  require 'appraisal'
35
21
  Appraisal::Task.new
36
- task default: :appraisal
22
+ %i(appraisal)
37
23
  end
38
-
39
- task 'db:test:prepare' => 'db:setup'
40
24
  end
25
+
26
+ task(:default).clear.enhance(default_task)
@@ -12,26 +12,11 @@ module Journaled
12
12
  raise KinesisTemporaryFailure
13
13
  end
14
14
 
15
- UNSPECIFIED = Object.new
16
- private_constant :UNSPECIFIED
17
-
18
- def perform(serialized_event:, partition_key:, stream_name: UNSPECIFIED, app_name: UNSPECIFIED)
19
- @serialized_event = serialized_event
20
- @partition_key = partition_key
21
- if app_name != UNSPECIFIED
22
- @stream_name = self.class.legacy_computed_stream_name(app_name: app_name)
23
- elsif stream_name != UNSPECIFIED && !stream_name.nil?
24
- @stream_name = stream_name
25
- else
26
- raise(ArgumentError, 'missing keyword: stream_name')
27
- end
28
-
29
- journal!
30
- end
15
+ def perform(*events, **legacy_kwargs)
16
+ events << legacy_kwargs if legacy_kwargs.present?
17
+ @kinesis_records = events.map { |e| KinesisRecord.new(**e.delete_if { |_k, v| v.nil? }) }
31
18
 
32
- def self.legacy_computed_stream_name(app_name:)
33
- env_var_name = [app_name&.upcase, 'JOURNALED_STREAM_NAME'].compact.join('_')
34
- ENV.fetch(env_var_name)
19
+ journal! if Journaled.enabled?
35
20
  end
36
21
 
37
22
  def kinesis_client_config
@@ -46,18 +31,22 @@ module Journaled
46
31
 
47
32
  private
48
33
 
49
- attr_reader :serialized_event, :partition_key, :stream_name
34
+ KinesisRecord = Struct.new(:serialized_event, :partition_key, :stream_name, keyword_init: true) do
35
+ def initialize(serialized_event:, partition_key:, stream_name:)
36
+ super(serialized_event: serialized_event, partition_key: partition_key, stream_name: stream_name)
37
+ end
50
38
 
51
- def journal!
52
- kinesis_client.put_record record if Journaled.enabled?
39
+ def to_h
40
+ { stream_name: stream_name, data: serialized_event, partition_key: partition_key }
41
+ end
53
42
  end
54
43
 
55
- def record
56
- {
57
- stream_name: stream_name,
58
- data: serialized_event,
59
- partition_key: partition_key,
60
- }
44
+ attr_reader :kinesis_records
45
+
46
+ def journal!
47
+ kinesis_records.map do |record|
48
+ kinesis_client.put_record(**record.to_h)
49
+ end
61
50
  end
62
51
 
63
52
  def kinesis_client
@@ -39,8 +39,8 @@ module Journaled::Changes
39
39
  end
40
40
  end
41
41
 
42
- def update_columns(attributes, force: false)
43
- unless force || self.class.journaled_attribute_names.empty?
42
+ def update_columns(attributes, opts = { force: false })
43
+ unless opts[:force] || self.class.journaled_attribute_names.empty?
44
44
  conflicting_journaled_attribute_names = self.class.journaled_attribute_names & attributes.keys.map(&:to_sym)
45
45
  raise(<<~ERROR) if conflicting_journaled_attribute_names.present?
46
46
  #update_columns aborted by Journaled::Changes due to journaled attributes:
@@ -56,7 +56,7 @@ module Journaled::Changes
56
56
  end
57
57
 
58
58
  class_methods do
59
- def journal_changes_to(*attribute_names, as:, enqueue_with: {}) # rubocop:disable Naming/UncommunicativeMethodParamName
59
+ def journal_changes_to(*attribute_names, as:, enqueue_with: {}) # rubocop:disable Naming/MethodParameterName
60
60
  if attribute_names.empty? || attribute_names.any? { |n| !n.is_a?(Symbol) }
61
61
  raise "one or more symbol attribute_name arguments is required"
62
62
  end
@@ -69,8 +69,8 @@ module Journaled::Changes
69
69
  end
70
70
 
71
71
  if Rails::VERSION::MAJOR > 5 || (Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR >= 2)
72
- def delete(id_or_array, force: false)
73
- if force || journaled_attribute_names.empty?
72
+ def delete(id_or_array, opts = { force: false })
73
+ if opts[:force] || journaled_attribute_names.empty?
74
74
  where(primary_key => id_or_array).delete_all(force: true)
75
75
  else
76
76
  raise(<<~ERROR)
@@ -2,20 +2,20 @@ class Journaled::Change
2
2
  include Journaled::Event
3
3
 
4
4
  attr_reader :table_name,
5
- :record_id,
6
- :database_operation,
7
- :logical_operation,
8
- :changes,
9
- :journaled_stream_name,
10
- :journaled_enqueue_opts,
11
- :actor
5
+ :record_id,
6
+ :database_operation,
7
+ :logical_operation,
8
+ :changes,
9
+ :journaled_stream_name,
10
+ :journaled_enqueue_opts,
11
+ :actor
12
12
 
13
13
  journal_attributes :table_name,
14
- :record_id,
15
- :database_operation,
16
- :logical_operation,
17
- :changes,
18
- :actor
14
+ :record_id,
15
+ :database_operation,
16
+ :logical_operation,
17
+ :changes,
18
+ :actor
19
19
 
20
20
  def initialize(table_name:,
21
21
  record_id:,
@@ -1,5 +1,6 @@
1
1
  class Journaled::ChangeWriter
2
2
  attr_reader :model, :change_definition
3
+
3
4
  delegate :attribute_names, :logical_operation, to: :change_definition
4
5
 
5
6
  def initialize(model:, change_definition:)
@@ -52,8 +53,8 @@ class Journaled::ChangeWriter
52
53
  private
53
54
 
54
55
  def pluck_changed_values(change_hash, index:)
55
- change_hash.each_with_object({}) do |(k, v), result|
56
- result[k] = v[index]
56
+ change_hash.transform_values do |v|
57
+ v[index]
57
58
  end
58
59
  end
59
60
 
@@ -63,7 +63,7 @@ module Journaled::Event
63
63
  end
64
64
 
65
65
  included do
66
- cattr_accessor(:journaled_enqueue_opts, instance_writer: false) { {} }
66
+ class_attribute :journaled_enqueue_opts, default: {}
67
67
 
68
68
  journal_attributes :id, :event_type, :created_at
69
69
  end
@@ -26,34 +26,51 @@ class Journaled::Writer
26
26
 
27
27
  def journal!
28
28
  validate!
29
- Journaled::DeliveryJob
30
- .set(journaled_enqueue_opts.reverse_merge(priority: Journaled.job_priority))
31
- .perform_later(delivery_perform_args)
29
+
30
+ ActiveSupport::Notifications.instrument('journaled.event.stage', event: journaled_event, **journaled_enqueue_opts) do
31
+ if Journaled::Connection.available?
32
+ Journaled::Connection.stage!(journaled_event)
33
+ else
34
+ self.class.enqueue!(journaled_event)
35
+ end
36
+ end
37
+ end
38
+
39
+ def self.enqueue!(*events)
40
+ events.group_by(&:journaled_enqueue_opts).each do |enqueue_opts, batch|
41
+ job_opts = enqueue_opts.reverse_merge(priority: Journaled.job_priority)
42
+ ActiveSupport::Notifications.instrument('journaled.batch.enqueue', batch: batch, **job_opts) do
43
+ Journaled::DeliveryJob.set(job_opts).perform_later(*delivery_perform_args(batch))
44
+
45
+ batch.each { |event| ActiveSupport::Notifications.instrument('journaled.event.enqueue', event: event, **job_opts) }
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.delivery_perform_args(events)
51
+ events.map do |event|
52
+ {
53
+ serialized_event: event.journaled_attributes.to_json,
54
+ partition_key: event.journaled_partition_key,
55
+ stream_name: event.journaled_stream_name,
56
+ }
57
+ end
32
58
  end
33
59
 
34
60
  private
35
61
 
36
62
  attr_reader :journaled_event
63
+
37
64
  delegate(*EVENT_METHOD_NAMES, to: :journaled_event)
38
65
 
39
66
  def validate!
67
+ serialized_event = journaled_event.journaled_attributes.to_json
68
+
40
69
  schema_validator('base_event').validate! serialized_event
41
70
  schema_validator('tagged_event').validate! serialized_event if journaled_event.tagged?
42
71
  schema_validator(journaled_schema_name).validate! serialized_event
43
72
  end
44
73
 
45
- def delivery_perform_args
46
- {
47
- serialized_event: serialized_event,
48
- partition_key: journaled_partition_key,
49
- stream_name: journaled_stream_name,
50
- }
51
- end
52
-
53
- def serialized_event
54
- @serialized_event ||= journaled_attributes.to_json
55
- end
56
-
57
74
  def schema_validator(schema_name)
58
75
  Journaled::JsonSchemaModel::Validator.new(schema_name)
59
76
  end
@@ -0,0 +1,48 @@
1
+ module Journaled
2
+ module Connection
3
+ class << self
4
+ def available?
5
+ Journaled.transactional_batching_enabled && transaction_open?
6
+ end
7
+
8
+ def stage!(event)
9
+ raise TransactionSafetyError, <<~MSG unless transaction_open?
10
+ Transaction not available! By default, journaled event batching requires an open database transaction.
11
+ MSG
12
+
13
+ connection.current_transaction._journaled_staged_events << event
14
+ end
15
+
16
+ private
17
+
18
+ def transaction_open?
19
+ connection.transaction_open?
20
+ end
21
+
22
+ def connection
23
+ if Journaled.queue_adapter.in? %w(delayed delayed_job)
24
+ Delayed::Job.connection
25
+ elsif Journaled.queue_adapter == 'good_job'
26
+ GoodJob::BaseRecord.connection
27
+ elsif Journaled.queue_adapter == 'que'
28
+ Que::ActiveRecord::Model.connection
29
+ elsif Journaled.queue_adapter == 'test' && Rails.env.test?
30
+ ActiveRecord::Base.connection
31
+ else
32
+ raise "Unsupported adapter: #{Journaled.queue_adapter}"
33
+ end
34
+ end
35
+ end
36
+
37
+ module TestOnlyBehaviors
38
+ def transaction_open?
39
+ # Transactional fixtures wrap all tests in an outer, non-joinable transaction:
40
+ super && (connection.open_transactions > 1 || connection.current_transaction.joinable?)
41
+ end
42
+ end
43
+
44
+ class << self
45
+ prepend TestOnlyBehaviors if Rails.env.test?
46
+ end
47
+ end
48
+ end
@@ -4,6 +4,11 @@ module Journaled
4
4
  ActiveSupport.on_load(:active_job) do
5
5
  Journaled.detect_queue_adapter! unless Journaled.development_or_test?
6
6
  end
7
+
8
+ ActiveSupport.on_load(:active_record) do
9
+ require 'journaled/transaction_ext'
10
+ ActiveRecord::ConnectionAdapters::Transaction.prepend Journaled::TransactionExt
11
+ end
7
12
  end
8
13
  end
9
14
  end
@@ -0,0 +1,3 @@
1
+ module Journaled
2
+ class TransactionSafetyError < StandardError; end
3
+ end
@@ -1,14 +1,15 @@
1
1
  module Journaled::RelationChangeProtection
2
- def update_all(updates, force: false) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
3
- unless force || !@klass.respond_to?(:journaled_attribute_names) || @klass.journaled_attribute_names.empty?
4
- conflicting_journaled_attribute_names = if updates.is_a?(Hash)
5
- @klass.journaled_attribute_names & updates.keys.map(&:to_sym)
6
- elsif updates.is_a?(String)
7
- @klass.journaled_attribute_names.select do |a|
8
- updates.match?(/\b(?<!')#{a}(?!')\b/)
9
- end
10
- else
11
- raise "unsupported type '#{updates.class}' for 'updates'"
2
+ def update_all(updates, opts = { force: false }) # rubocop:disable Metrics/AbcSize
3
+ unless opts[:force] || !@klass.respond_to?(:journaled_attribute_names) || @klass.journaled_attribute_names.empty?
4
+ conflicting_journaled_attribute_names = case updates
5
+ when Hash
6
+ @klass.journaled_attribute_names & updates.keys.map(&:to_sym)
7
+ when String
8
+ @klass.journaled_attribute_names.select do |a|
9
+ updates.match?(/\b(?<!')#{a}(?!')\b/)
10
+ end
11
+ else
12
+ raise "unsupported type '#{updates.class}' for 'updates'"
12
13
  end
13
14
  raise(<<~ERROR) if conflicting_journaled_attribute_names.present?
14
15
  .update_all aborted by Journaled::Changes due to journaled attributes: