state_machines-activerecord 0.9.0 → 0.100.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/README.md +16 -3
  4. data/lib/state_machines/integrations/active_record/locale.rb +12 -9
  5. data/lib/state_machines/integrations/active_record/version.rb +3 -1
  6. data/lib/state_machines/integrations/active_record.rb +72 -109
  7. data/lib/state_machines-activerecord.rb +2 -0
  8. metadata +36 -142
  9. data/test/files/en.yml +0 -5
  10. data/test/files/models/post.rb +0 -11
  11. data/test/integration_test.rb +0 -25
  12. data/test/machine_by_default_test.rb +0 -16
  13. data/test/machine_errors_test.rb +0 -19
  14. data/test/machine_multiple_test.rb +0 -17
  15. data/test/machine_nested_action_test.rb +0 -38
  16. data/test/machine_unmigrated_test.rb +0 -14
  17. data/test/machine_with_aliased_attribute_test.rb +0 -23
  18. data/test/machine_with_callbacks_test.rb +0 -172
  19. data/test/machine_with_column_state_attribute_test.rb +0 -44
  20. data/test/machine_with_complex_pluralization_scopes_test.rb +0 -16
  21. data/test/machine_with_conflicting_predicate_test.rb +0 -18
  22. data/test/machine_with_conflicting_state_name_test.rb +0 -29
  23. data/test/machine_with_custom_attribute_test.rb +0 -21
  24. data/test/machine_with_default_scope_test.rb +0 -18
  25. data/test/machine_with_different_column_default_test.rb +0 -27
  26. data/test/machine_with_different_integer_column_default_test.rb +0 -29
  27. data/test/machine_with_dirty_attribute_and_custom_attributes_during_loopback_test.rb +0 -24
  28. data/test/machine_with_dirty_attribute_and_state_events_test.rb +0 -20
  29. data/test/machine_with_dirty_attributes_and_custom_attribute_test.rb +0 -32
  30. data/test/machine_with_dirty_attributes_during_loopback_test.rb +0 -22
  31. data/test/machine_with_dirty_attributes_test.rb +0 -35
  32. data/test/machine_with_dynamic_initial_state_test.rb +0 -99
  33. data/test/machine_with_event_attributes_on_autosave_test.rb +0 -48
  34. data/test/machine_with_event_attributes_on_custom_action_test.rb +0 -41
  35. data/test/machine_with_event_attributes_on_save_bang_test.rb +0 -82
  36. data/test/machine_with_event_attributes_on_save_test.rb +0 -244
  37. data/test/machine_with_event_attributes_on_validation_test.rb +0 -143
  38. data/test/machine_with_events_test.rb +0 -13
  39. data/test/machine_with_failed_action_test.rb +0 -40
  40. data/test/machine_with_failed_after_callbacks_test.rb +0 -35
  41. data/test/machine_with_failed_before_callbacks_test.rb +0 -36
  42. data/test/machine_with_initialized_state_test.rb +0 -41
  43. data/test/machine_with_internationalization_test.rb +0 -180
  44. data/test/machine_with_loopback_test.rb +0 -22
  45. data/test/machine_with_non_column_state_attribute_defined_test.rb +0 -29
  46. data/test/machine_with_same_column_default_test.rb +0 -26
  47. data/test/machine_with_same_integer_column_default_test.rb +0 -30
  48. data/test/machine_with_scopes_and_joins_test.rb +0 -38
  49. data/test/machine_with_scopes_and_owner_subclass_test.rb +0 -27
  50. data/test/machine_with_scopes_test.rb +0 -70
  51. data/test/machine_with_state_driven_validations_test.rb +0 -30
  52. data/test/machine_with_states_test.rb +0 -13
  53. data/test/machine_with_static_initial_state_test.rb +0 -167
  54. data/test/machine_with_transactions_test.rb +0 -26
  55. data/test/machine_with_validations_and_custom_attribute_test.rb +0 -21
  56. data/test/machine_with_validations_test.rb +0 -47
  57. data/test/machine_without_database_test.rb +0 -20
  58. data/test/machine_without_transactions_test.rb +0 -26
  59. data/test/model_test.rb +0 -12
  60. data/test/test_helper.rb +0 -58
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 570d0e44cce8dade9ceb135c6e24cfcafb7dc0325ee8f40d99658d663072e234
4
- data.tar.gz: 2195bc14693c748c3248c3ddd42bc0a26faccbdf78285c6d24f17e54bd3ec0fd
3
+ metadata.gz: 8da92c0d0872bf79ae81017dd5d16459e5222d10ee304390ddff3af14f1e1e05
4
+ data.tar.gz: c7ebd9fbdff4ee5bd2b0cca81b5daad186bb92157eddcb912829685571aa84eb
5
5
  SHA512:
6
- metadata.gz: dde59866fccfe87fbd05658fd0f0b5d92c2129ff3e448c75983722e7a5468cc3201f63dfb319442a60e536e4774ecfd61429d0e979e454abba568e10a1b7cc15
7
- data.tar.gz: f78734f5c798ae8f468d69d22e1983b95839dcf724e6b7c1e6563f81a3f1976930b6ff52d275163feaccd0419888a9d754f47d587a112df019a9c9804f21bd31
6
+ metadata.gz: 66d176f9efbc9d44ac2d5a5786f078e593d3590a99d855804ff4775649b18abae64264781f4ddc36a60ac253366aea0a9c02d3d1712d680d78fe443fffd437e7
7
+ data.tar.gz: f5e7a924679499d596439009b9bad2633af5389069959016595d31c44243f7551893cd2e53769e5e1b3a99b979f8d6ad1fa24d86caaa9134f0aa63528739b108
data/LICENSE.txt CHANGED
@@ -1,5 +1,5 @@
1
1
  Copyright (c) 2006-2012 Aaron Pfeifer
2
- Copyright (c) 2014-2023 Abdelkader Boudih
2
+ Copyright (c) 2014-2025 Abdelkader Boudih
3
3
 
4
4
  MIT License
5
5
 
data/README.md CHANGED
@@ -1,11 +1,15 @@
1
- [![Build Status](https://travis-ci.com/state-machines/state_machines-activerecord.svg?branch=master)](https://travis-ci.com/state-machines/state_machines-activerecord)
2
- [![Code Climate](https://codeclimate.com/github/state-machines/state_machines-activerecord.svg)](https://codeclimate.com/github/state-machines/state_machines-activerecord)
1
+ [![Build Status](https://github.com/state-machines/state_machines-activerecord/actions/workflows/ruby.yml/badge.svg)](https://github.com/state-machines/state_machines-activerecord/actions/workflows/ruby.yml)
3
2
 
4
3
  # StateMachines Active Record Integration
5
4
 
6
- The Active Record 5.1+ integration adds support for database transactions, automatically
5
+ The Active Record 7.2+ integration adds support for database transactions, automatically
7
6
  saving the record, named scopes, validation errors.
8
7
 
8
+ ## Requirements
9
+
10
+ - Ruby 3.2+
11
+ - Rails 7.2+
12
+
9
13
  ## Installation
10
14
 
11
15
  Add this line to your application's Gemfile:
@@ -64,6 +68,15 @@ Vehicle.with_state(:parked) # also plural #with_states
64
68
  Vehicle.without_states(:first_gear, :second_gear) # also singular #without_state
65
69
  ```
66
70
 
71
+ #### Transparent Scopes
72
+ State scopes will return all records when `nil` is passed, making them perfect for search filters:
73
+
74
+ ```ruby
75
+ Vehicle.with_state(nil) # Returns all vehicles
76
+ Vehicle.with_state(params[:state]) # Returns all vehicles if params[:state] is nil
77
+ Vehicle.where(color: 'red').with_state(nil) # Returns all red vehicles (chainable)
78
+ ```
79
+
67
80
  ### State driven validations
68
81
 
69
82
  As mentioned in `StateMachines::Machine#state`, you can define behaviors,
@@ -1,12 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Use lazy evaluation to avoid circular dependencies with frozen default_messages
4
+ # This ensures messages can be updated after gem loading while maintaining thread safety
1
5
  { en: {
2
- activerecord: {
3
- errors: {
4
- messages: {
5
- invalid: StateMachines::Machine.default_messages[:invalid],
6
- invalid_event: StateMachines::Machine.default_messages[:invalid_event] % ['%{state}'],
7
- invalid_transition: StateMachines::Machine.default_messages[:invalid_transition] % ['%{event}']
8
- }
9
- }
6
+ activerecord: {
7
+ errors: {
8
+ messages: {
9
+ invalid: ->(*) { StateMachines::Machine.default_messages[:invalid] },
10
+ invalid_event: ->(*) { format(StateMachines::Machine.default_messages[:invalid_event], '%<state>s') },
11
+ invalid_transition: ->(*) { format(StateMachines::Machine.default_messages[:invalid_transition], '%<event>s') }
12
+ }
10
13
  }
14
+ }
11
15
  } }
12
-
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module StateMachines
2
4
  module Integrations
3
5
  module ActiveRecord
4
- VERSION = '0.9.0'
6
+ VERSION = '0.100.0'
5
7
  end
6
8
  end
7
9
  end
@@ -3,7 +3,7 @@ require 'active_record'
3
3
  require 'state_machines/integrations/active_record/version'
4
4
 
5
5
  module StateMachines
6
- module Integrations #:nodoc:
6
+ module Integrations # :nodoc:
7
7
  # Adds support for integrating state machines with ActiveRecord models.
8
8
  #
9
9
  # == Examples
@@ -78,16 +78,14 @@ module StateMachines
78
78
  # === Security implications
79
79
  #
80
80
  # Beware that public event attributes mean that events can be fired
81
- # whenever mass-assignment is being used. If you want to prevent malicious
82
- # users from tampering with events through URLs / forms, the attribute
83
- # should be protected like so:
84
- #
85
- # class Vehicle < ApplicationRecord
86
- # attr_protected :state_event
87
- # # attr_accessible ... # Alternative technique
88
- #
89
- # state_machine do
90
- # ...
81
+ # whenever mass-assignment is being used. If you want to prevent malicious
82
+ # users from tampering with events through URLs / forms, you should use
83
+ # Rails' strong parameters to control which attributes are permitted:
84
+ #
85
+ # class VehiclesController < ApplicationController
86
+ # def vehicle_params
87
+ # params.require(:vehicle).permit(:color, :make, :model)
88
+ # # Exclude state_event to prevent tampering
91
89
  # end
92
90
  # end
93
91
  #
@@ -95,8 +93,7 @@ module StateMachines
95
93
  # you can build two state machines (one public and one protected) like so:
96
94
  #
97
95
  # class Vehicle < ApplicationRecord
98
- # attr_protected :state_event # Prevent access to events in the first machine
99
- #
96
+ # # Define private machine
100
97
  # state_machine do
101
98
  # # Define private events here
102
99
  # end
@@ -105,6 +102,8 @@ module StateMachines
105
102
  # state_machine :public_state, :attribute => :state do
106
103
  # # Define public events here
107
104
  # end
105
+ #
106
+ # # Control access via strong parameters in your controller
108
107
  # end
109
108
  #
110
109
  # == Transactions
@@ -199,33 +198,43 @@ module StateMachines
199
198
  #
200
199
  # == Scopes
201
200
  #
202
- # To assist in filtering models with specific states, a series of named
203
- # scopes are defined on the model for finding records with or without a
201
+ # To assist in filtering models with specific states, a series of scopes
202
+ # are defined on the model for finding records with or without a
204
203
  # particular set of states.
205
204
  #
206
- # These named scopes are essentially the functional equivalent of the
205
+ # These scopes are essentially the functional equivalent of the
207
206
  # following definitions:
208
207
  #
209
208
  # class Vehicle < ApplicationRecord
210
209
  # # with_states also aliased to with_state
210
+ # scope :with_states, ->(states) { states.present? ? where(state: states) : all }
211
211
  #
212
- # named_scope :without_states, lambda {|*states| {:conditions => ['state NOT IN (?)', states]}}
213
212
  # # without_states also aliased to without_state
213
+ # scope :without_states, ->(states) { states.present? ? where.not(state: states) : all }
214
214
  # end
215
215
  #
216
216
  # *Note*, however, that the states are converted to their stored values
217
217
  # before being passed into the query.
218
218
  #
219
- # Because of the way named scopes work in ActiveRecord, they can be
219
+ # Because of the way scopes work in ActiveRecord, they can be
220
220
  # chained like so:
221
221
  #
222
- # Vehicle.with_state(:parked).all(:order => 'id DESC')
222
+ # Vehicle.with_state(:parked).order(id: :desc)
223
223
  #
224
224
  # Note that states can also be referenced by the string version of their
225
225
  # name:
226
226
  #
227
227
  # Vehicle.with_state('parked')
228
228
  #
229
+ # === Transparent Scopes
230
+ #
231
+ # When `nil` is passed to any of the state scopes, they return `all` records
232
+ # without applying any filters. This allows for more flexible scope chaining
233
+ # in search interfaces:
234
+ #
235
+ # Vehicle.with_state(params[:state]) # Returns all vehicles if params[:state] is nil
236
+ # Vehicle.where(color: 'red').with_state(nil) # Returns all red vehicles
237
+ #
229
238
  # == Callbacks
230
239
  #
231
240
  # All before/after transition callbacks defined for ActiveRecord models
@@ -266,7 +275,7 @@ module StateMachines
266
275
  # your callback to roll back. You can work around this issue like so:
267
276
  #
268
277
  # class TransitionLog < ApplicationRecord
269
- # establish_connection Rails.env.to_sym
278
+ # connects_to database: { writing: :primary, reading: :primary }
270
279
  # end
271
280
  #
272
281
  # class Vehicle < ApplicationRecord
@@ -279,7 +288,7 @@ module StateMachines
279
288
  # end
280
289
  # end
281
290
  #
282
- # The +TransitionLog+ model establishes a second connection to the database
291
+ # The +TransitionLog+ model establishes a separate connection to the database
283
292
  # that allows new records to be saved without being affected by rollbacks
284
293
  # in the +Vehicle+ model's transaction.
285
294
  #
@@ -304,65 +313,9 @@ module StateMachines
304
313
  # * (-) end transaction (if enabled)
305
314
  # * (9) after_commit
306
315
  #
307
- # == Observers
308
- #
309
- # In addition to support for ActiveRecord-like hooks, there is additional
310
- # support for ActiveRecord observers. Because of the way ActiveRecord
311
- # observers are designed, there is less flexibility around the specific
312
- # transitions that can be hooked in. However, a large number of hooks
313
- # *are* supported. For example, if a transition for a record's +state+
314
- # attribute changes the state from +parked+ to +idling+ via the +ignite+
315
- # event, the following observer methods are supported:
316
- # * before/after/after_failure_to-_ignite_from_parked_to_idling
317
- # * before/after/after_failure_to-_ignite_from_parked
318
- # * before/after/after_failure_to-_ignite_to_idling
319
- # * before/after/after_failure_to-_ignite
320
- # * before/after/after_failure_to-_transition_state_from_parked_to_idling
321
- # * before/after/after_failure_to-_transition_state_from_parked
322
- # * before/after/after_failure_to-_transition_state_to_idling
323
- # * before/after/after_failure_to-_transition_state
324
- # * before/after/after_failure_to-_transition
325
- #
326
- # The following class shows an example of some of these hooks:
327
- #
328
- # class VehicleObserver < ActiveRecord::Observer
329
- # def before_save(vehicle)
330
- # # log message
331
- # end
332
- #
333
- # # Callback for :ignite event *before* the transition is performed
334
- # def before_ignite(vehicle, transition)
335
- # # log message
336
- # end
337
- #
338
- # # Callback for :ignite event *after* the transition has been performed
339
- # def after_ignite(vehicle, transition)
340
- # # put on seatbelt
341
- # end
342
- #
343
- # # Generic transition callback *before* the transition is performed
344
- # def after_transition(vehicle, transition)
345
- # Audit.log(vehicle, transition)
346
- # end
347
- # end
348
- #
349
- # More flexible transition callbacks can be defined directly within the
350
- # model as described in StateMachines::Machine#before_transition
351
- # and StateMachines::Machine#after_transition.
352
- #
353
- # To define a single observer for multiple state machines:
354
- #
355
- # class StateMachineObserver < ActiveRecord::Observer
356
- # observe Vehicle, Switch, Project
357
- #
358
- # def after_transition(record, transition)
359
- # Audit.log(record, transition)
360
- # end
361
- # end
362
- #
363
316
  # == Internationalization
364
317
  #
365
- # In Rails 2.2+, any error message that is generated from performing invalid
318
+ # Any error message that is generated from performing invalid
366
319
  # transitions can be localized. The following default translations are used:
367
320
  #
368
321
  # en:
@@ -375,9 +328,6 @@ module StateMachines
375
328
  # # %{value} = attribute value, %{event} = Human event name, %{state} = Human current state name
376
329
  # invalid_transition: "cannot transition via %{event}"
377
330
  #
378
- # Notice that the interpolation syntax is %{key} in Rails 3+. In Rails 2.x,
379
- # the appropriate syntax is {{key}}.
380
- #
381
331
  # You can override these for a specific model like so:
382
332
  #
383
333
  # en:
@@ -417,7 +367,7 @@ module StateMachines
417
367
  include ActiveModel
418
368
 
419
369
  # The default options to use for state machines using this integration
420
- @defaults = {:action => :save, use_transactions: true}
370
+ @defaults = { action: :save, use_transactions: true }
421
371
  class << self
422
372
  # Classes that inherit from ActiveRecord::Base will automatically use
423
373
  # the ActiveRecord integration.
@@ -435,28 +385,29 @@ module StateMachines
435
385
 
436
386
  # Gets the db default for the machine's attribute
437
387
  def owner_class_attribute_default
438
- if owner_class.connected? && owner_class.table_exists?
439
- owner_class.column_defaults[attribute.to_s]
440
- end
388
+ return unless owner_class.connected? && owner_class.table_exists?
389
+
390
+ owner_class.column_defaults[attribute.to_s]
441
391
  end
442
392
 
443
393
  def define_state_initializer
444
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
394
+ define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
445
395
  def initialize(attributes = nil, *)
446
396
  super(attributes) do |*args|
447
- scoped_attributes = (attributes || {}).merge(self.class.scope_attributes)
397
+ attributes = (attributes || {}).transform_keys { |key| self.class.attribute_aliases[key.to_s] || key }
398
+ scoped_attributes = attributes.merge(self.class.scope_attributes)
448
399
 
449
400
  self.class.state_machines.initialize_states(self, {}, scoped_attributes)
450
401
  yield(*args) if block_given?
451
402
  end
452
403
  end
453
- end_eval
404
+ END_EVAL
454
405
  end
455
406
 
456
407
  # Uses around callbacks to run state events if using the :save hook
457
408
  def define_action_hook
458
409
  if action_hook == :save
459
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
410
+ define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
460
411
  def save(*, **)
461
412
  self.class.state_machine(#{name.inspect}).send(:around_save, self) { super }
462
413
  end
@@ -469,34 +420,44 @@ module StateMachines
469
420
  def changed_for_autosave?
470
421
  super || self.class.state_machines.any? {|name, machine| machine.action == :save && machine.read(self, :event)}
471
422
  end
472
- end_eval
423
+ END_EVAL
473
424
  else
474
425
  super
475
426
  end
476
427
  end
477
428
 
478
429
  # Runs state events around the machine's :save action
479
- def around_save(object)
480
- object.class.state_machines.transitions(object, action).perform { yield }
430
+ def around_save(object, &)
431
+ # Pass fiber: false to avoid deadlocks with ActiveRecord's LoadInterlockAwareMonitor
432
+ object.class.state_machines.transitions(object, action, fiber: false).perform(&)
481
433
  end
482
434
 
483
435
  # Creates a scope for finding records *with* a particular state or
484
436
  # states for the attribute
485
437
  def create_with_scope(name)
486
- create_scope(name, ->(values) { ["#{attribute_column} IN (?)", values] })
438
+ attr_name = attribute
439
+ lambda do |klass, values|
440
+ if values.present?
441
+ klass.where(attr_name => values)
442
+ else
443
+ klass.all
444
+ end
445
+ end
487
446
  end
488
447
 
489
448
  # Creates a scope for finding records *without* a particular state or
490
449
  # states for the attribute
491
450
  def create_without_scope(name)
492
- create_scope(name, ->(values) { ["#{attribute_column} NOT IN (?)", values] })
451
+ attr_name = attribute
452
+ lambda do |klass, values|
453
+ if values.present?
454
+ klass.where.not(attr_name => values)
455
+ else
456
+ klass.all
457
+ end
458
+ end
493
459
  end
494
460
 
495
- # Generates the fully-qualifed column name for this machine's attribute
496
- def attribute_column
497
- connection = owner_class.connection
498
- "#{connection.quote_table_name(owner_class.table_name)}.#{connection.quote_column_name(attribute)}"
499
- end
500
461
 
501
462
  # Runs a new database transaction, rolling back any changes by raising
502
463
  # an ActiveRecord::Rollback exception if the yielded block fails
@@ -515,17 +476,19 @@ module StateMachines
515
476
 
516
477
  private
517
478
 
518
- # Defines a new named scope with the given name
519
- def create_scope(name, scope)
520
- lambda { |model, values| model.where(scope.call(values)) }
521
- end
522
479
 
523
- # ActiveModel's use of method_missing / respond_to for attribute methods
524
- # breaks both ancestor lookups and defined?(super). Need to special-case
525
- # the existence of query attribute methods.
526
- def owner_class_ancestor_has_method?(scope, method)
527
- scope == :instance && method == "#{attribute}?" ? owner_class : super
528
- end
480
+ # Generates the results for the given scope based on one or more states to filter by
481
+ def run_scope(scope, machine, klass, states)
482
+ values = states.flatten.compact.map { |state| machine.states.fetch(state).value }
483
+ scope.call(klass, values)
484
+ end
485
+
486
+ # ActiveModel's use of method_missing / respond_to for attribute methods
487
+ # breaks both ancestor lookups and defined?(super). Need to special-case
488
+ # the existence of query attribute methods.
489
+ def owner_class_ancestor_has_method?(scope, method)
490
+ scope == :instance && method == "#{attribute}?" ? owner_class : super
491
+ end
529
492
  end
530
493
  register(ActiveRecord)
531
494
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_support'
2
4
  require 'state_machines/integrations/active_record'
3
5