statesman 13.3.0 → 13.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5de6588300b8aa3559f3ef4691e865c8b4571b029029671704aa8249b6fccb48
4
- data.tar.gz: 2b70040b208dfa076b5633640668f5b964a148cd7d6ea4e1cd7a6671dc29e5cd
3
+ metadata.gz: 706f8fde91c1454f1ab6a7082c1317341eb5855f4c4c418e73bca0246b677a0e
4
+ data.tar.gz: 6cc6d3c4c43fc38f751dd361db4a7dd622b02b4ca07afd94315bef7dc433f23d
5
5
  SHA512:
6
- metadata.gz: 33129e2eb00defefabd11b7edcf85efb709eed969c68897fc2b321927e8b7b47a7e4abf474365ab4ed9b9b5916e06937752a312520e205650427992de93adf2e
7
- data.tar.gz: 0c33cba64756fc17cb293c0efbd3dd752d7741c8fb7469407ce1e5eb51df59a4c3ff9b989e433ed5220716c6c378a97331e7cb4ffb64fe97dbd9521be6dd2877
6
+ metadata.gz: fbcf95c6f1bfdbb867768072d5cfe47d0476d26c9f37ad038eb7563ee541cf03dd4a2e66c77fe808926732e9f51a6746be77892b171148756d07e24e3e11b368
7
+ data.tar.gz: b70bb3d87a2f6923a4f42b1d2fda278caae324d2339731a2abdd501ac76f4a43fd70a9128a528ae55e1e05173d944b6d328b2ebeedc6c56a8a128e0d7b48371d
@@ -21,7 +21,52 @@ jobs:
21
21
  bundler-cache: true
22
22
  - run: bundle exec rubocop --extra-details --display-style-guide --parallel --force-exclusion
23
23
 
24
+ bundle:
25
+ strategy:
26
+ fail-fast: false
27
+ matrix:
28
+ ruby-version: ["3.3", "3.4", "4.0"]
29
+ rails-version:
30
+ - "7.2"
31
+ - "8.0"
32
+ - "8.1"
33
+ - "main"
34
+ runs-on: ubuntu-latest
35
+ env:
36
+ RAILS_VERSION: ${{ matrix.rails-version }}
37
+ BUNDLE_PATH: vendor/bundle
38
+ steps:
39
+ - uses: actions/checkout@v5
40
+ - name: Set up Ruby
41
+ uses: ruby/setup-ruby@v1
42
+ with:
43
+ ruby-version: "${{ matrix.ruby-version }}"
44
+ - name: Resolve gems
45
+ run: bundle lock
46
+ - name: Restore gems
47
+ id: cache
48
+ uses: actions/cache/restore@v6
49
+ with:
50
+ path: vendor/bundle
51
+ key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-${{ hashFiles('Gemfile.lock') }}
52
+ restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
53
+ - name: Install gems
54
+ if: steps.cache.outputs.cache-hit != 'true'
55
+ # The lockfile is stored inside the cached path so downstream jobs, which can't
56
+ # compute this key, can restore the most recent entry and use the same gems.
57
+ run: |
58
+ bundle install --jobs 4
59
+ bundle clean
60
+ cp Gemfile.lock vendor/bundle/Gemfile.lock
61
+ - name: Save gems
62
+ if: steps.cache.outputs.cache-hit != 'true'
63
+ uses: actions/cache/save@v6
64
+ with:
65
+ path: vendor/bundle
66
+ key: ${{ steps.cache.outputs.cache-primary-key }}
67
+
24
68
  sqlite:
69
+ needs: bundle
25
70
  strategy:
26
71
  fail-fast: false
27
72
  matrix:
@@ -37,18 +82,33 @@ jobs:
37
82
  runs-on: ubuntu-latest
38
83
  env:
39
84
  RAILS_VERSION: ${{ matrix.rails-version }}
85
+ BUNDLE_PATH: vendor/bundle
40
86
  steps:
41
87
  - uses: actions/checkout@v5
42
88
  - name: Set up Ruby
43
89
  uses: ruby/setup-ruby@v1
44
90
  with:
45
- bundler-cache: true
46
91
  ruby-version: "${{ matrix.ruby-version }}"
92
+ # Restore-only: the bundle job owns saving, so matrix jobs never race to write
93
+ # the same cache entry. The most recent entry for this prefix is the one the
94
+ # bundle job just resolved.
95
+ - name: Restore gems
96
+ uses: actions/cache/restore@v6
97
+ with:
98
+ path: vendor/bundle
99
+ key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
100
+ restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
101
+ fail-on-cache-miss: true
102
+ - name: Install gems
103
+ run: |
104
+ cp vendor/bundle/Gemfile.lock Gemfile.lock
105
+ bundle install --local
47
106
  - name: Run specs
48
107
  run: |
49
108
  bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
50
109
 
51
110
  postgres:
111
+ needs: bundle
52
112
  strategy:
53
113
  fail-fast: false
54
114
  matrix:
@@ -63,36 +123,37 @@ jobs:
63
123
  - ruby-version: "3.2"
64
124
  rails-version: "main"
65
125
  runs-on: ubuntu-latest
66
- services:
67
- postgres:
68
- image: postgres:${{ matrix.postgres-version }}
69
- env:
70
- POSTGRES_USER: postgres
71
- POSTGRES_DB: statesman_test
72
- POSTGRES_PASSWORD: statesman
73
- ports:
74
- - 5432:5432
75
- options: >-
76
- --health-cmd pg_isready
77
- --health-interval 10s
78
- --health-timeout 5s
79
- --health-retries 10
80
126
  env:
81
- DATABASE_URL: postgres://postgres:statesman@localhost/statesman_test
82
- DATABASE_DEPENDENCY_PORT: "5432"
127
+ DB: postgres
128
+ DB_IMAGE: postgres:${{ matrix.postgres-version }}
83
129
  RAILS_VERSION: ${{ matrix.rails-version }}
130
+ BUNDLE_PATH: vendor/bundle
84
131
  steps:
85
132
  - uses: actions/checkout@v5
86
133
  - name: Set up Ruby
87
134
  uses: ruby/setup-ruby@v1
88
135
  with:
89
- bundler-cache: true
90
136
  ruby-version: "${{ matrix.ruby-version }}"
137
+ # Restore-only: the bundle job owns saving, so matrix jobs never race to write
138
+ # the same cache entry. The most recent entry for this prefix is the one the
139
+ # bundle job just resolved.
140
+ - name: Restore gems
141
+ uses: actions/cache/restore@v6
142
+ with:
143
+ path: vendor/bundle
144
+ key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
145
+ restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
146
+ fail-on-cache-miss: true
147
+ - name: Install gems
148
+ run: |
149
+ cp vendor/bundle/Gemfile.lock Gemfile.lock
150
+ bundle install --local
91
151
  - name: Run specs
92
152
  run: |
93
153
  bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
94
154
 
95
155
  mysql:
156
+ needs: bundle
96
157
  strategy:
97
158
  fail-fast: false
98
159
  matrix:
@@ -108,32 +169,31 @@ jobs:
108
169
  rails-version: "main"
109
170
 
110
171
  runs-on: ubuntu-latest
111
- services:
112
- mysql:
113
- image: mysql:${{ matrix.mysql-version }}
114
- env:
115
- MYSQL_ROOT_PASSWORD: password
116
- MYSQL_USER: foobar
117
- MYSQL_PASSWORD: password
118
- MYSQL_DATABASE: statesman_test
119
- ports:
120
- - "3306:3306"
121
- options: >-
122
- --health-cmd "mysqladmin ping"
123
- --health-interval 10s
124
- --health-timeout 5s
125
- --health-retries 5
126
172
  env:
127
- DATABASE_URL: mysql2://foobar:password@127.0.0.1/statesman_test
128
- DATABASE_DEPENDENCY_PORT: "3306"
173
+ DB: mysql
174
+ DB_IMAGE: mysql:${{ matrix.mysql-version }}
129
175
  RAILS_VERSION: ${{ matrix.rails-version }}
176
+ BUNDLE_PATH: vendor/bundle
130
177
  steps:
131
178
  - uses: actions/checkout@v5
132
179
  - name: Set up Ruby
133
180
  uses: ruby/setup-ruby@v1
134
181
  with:
135
- bundler-cache: true
136
182
  ruby-version: "${{ matrix.ruby-version }}"
183
+ # Restore-only: the bundle job owns saving, so matrix jobs never race to write
184
+ # the same cache entry. The most recent entry for this prefix is the one the
185
+ # bundle job just resolved.
186
+ - name: Restore gems
187
+ uses: actions/cache/restore@v6
188
+ with:
189
+ path: vendor/bundle
190
+ key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
191
+ restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
192
+ fail-on-cache-miss: true
193
+ - name: Install gems
194
+ run: |
195
+ cp vendor/bundle/Gemfile.lock Gemfile.lock
196
+ bundle install --local
137
197
  - name: Run specs
138
198
  run: |
139
199
  bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.4.5
data/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v13.4.0 14th October 2026
9
+
10
+ ### Added
11
+
12
+ - `Statesman::BulkTransition`, which transitions many parents from one state to another in a single transaction with a fixed number of queries (a flip of the current `most_recent` rows, one `insert_all!`, and one cached-current-state update if configured). Works with the `ActiveRecord` adapter on PostgreSQL, MySQL (including `mysql_gaplock_protection`) and SQLite. Like `transition_to!`, it flips the previous transition and updates the cached current state, but it does not run guards, `before`/`after` callbacks or `ActiveRecord` validations/callbacks on the transition model. Supports per-parent overrides via `metadata_per_id` and `attributes_per_id`, and an optional block that is called inside the transaction with a lazy `ActiveRecord::Relation` over the newly inserted transitions. Parents not in the `from` state are skipped, so a batch is safe to re-run. Assumes a non-STI transition table and a single-column parent primary key.
13
+
8
14
  ## v13.3.0 4th August 2026
9
15
 
10
16
  ### Added
data/CONTRIBUTING.md CHANGED
@@ -11,6 +11,26 @@ ways you can help make this project better!
11
11
  - No need to add to the changelog, we will take care of updating it as we make
12
12
  releases.
13
13
 
14
+ ## Running tests
15
+
16
+ `bundle exec rake` runs the suite against SQLite, PostgreSQL and MySQL in
17
+ parallel. PostgreSQL and MySQL are started as throwaway containers in Docker via
18
+ [Testcontainers](https://github.com/testcontainers/testcontainers-ruby).
19
+
20
+ ```sh
21
+ bundle exec rake # all databases, in parallel
22
+ bundle exec rake spec:postgres # a single database
23
+ ```
24
+
25
+ `bundle exec rspec` runs against SQLite by default. Set `DB` to use a container,
26
+ or point `DATABASE_URL` at an existing database:
27
+
28
+ ```sh
29
+ DB=postgres bundle exec rspec # postgres:17
30
+ DB=mysql bundle exec rspec # mysql:8.4
31
+ DB=mysql DB_IMAGE=mysql:9.4 bundle exec rspec spec/statesman/machine_spec.rb
32
+ ```
33
+
14
34
  ## Style
15
35
 
16
36
  We use [Rubocop](https://github.com/bbatsov/rubocop) to help maintain a
data/Gemfile CHANGED
@@ -12,6 +12,9 @@ else
12
12
  gem "rails", "~> 8.0"
13
13
  end
14
14
 
15
+ # ActiveSupport 7.2 passes `quirks_mode:` to JSON, which json 3 no longer accepts.
16
+ gem "json", "< 3" if ENV['RAILS_VERSION'] == '7.2'
17
+
15
18
  group :development, :test do
16
19
  gem "ammeter", "~> 1.1"
17
20
  gem "gc_ruboconfig", "~> 6.0.0"
@@ -24,6 +27,8 @@ group :development, :test do
24
27
  gem "rspec-its", "~> 2.0"
25
28
  gem "rspec-rails", "~> 8.0"
26
29
  gem "sqlite3", "~> 2.9.0"
30
+ gem "testcontainers-mysql", "~> 0.2.0"
31
+ gem "testcontainers-postgres", "~> 0.2.0"
27
32
  gem "test-unit", "~> 3.3"
28
33
  gem "timecop", "~> 0.9.1"
29
34
  end
data/README.md CHANGED
@@ -674,6 +674,66 @@ returning the configured column, for generic tooling that needs to introspect
674
674
  or repair the cached column (e.g. a cache-repair rake task working across
675
675
  several models).
676
676
 
677
+ ## Bulk transitions
678
+
679
+ `Statesman::BulkTransition` moves many parents from one state to another in a
680
+ single transaction, with a fixed number of queries however many parents there
681
+ are, instead of the several queries per record that `transition_to!` costs. It
682
+ works with the `ActiveRecord` adapter on PostgreSQL, MySQL and SQLite.
683
+
684
+ ```ruby
685
+ bulk = Statesman::BulkTransition.new(
686
+ model_class: Order,
687
+ machine_class: OrderStateMachine,
688
+ from: :pending,
689
+ to: :confirmed,
690
+ )
691
+
692
+ transitioned_ids = bulk.call!(order_ids, metadata: { source: "import" })
693
+ ```
694
+
695
+ `call!` returns the ids of the parents that were actually transitioned. Parents
696
+ that aren't currently in the `from` state are silently skipped, so a batch is
697
+ safe to re-run.
698
+
699
+ It performs the same mechanical write as `transition_to!` — flipping the
700
+ current `most_recent` transition, inserting the new one, and updating the
701
+ parent's cached current state if you've called `configure_cached_current_state`
702
+ — but it deliberately **does not run** the machine's guards, its
703
+ `before_transition`/`after_transition` callbacks, or any validations and
704
+ callbacks on the transition model. If you need equivalent side effects,
705
+ reproduce them in the block (below).
706
+
707
+ Options:
708
+
709
+ - `metadata:` — applied to every new transition. `metadata_per_id:` overrides it
710
+ for individual parents, keyed by parent id (`1` and `"1"` match the same
711
+ parent).
712
+ - `attributes_to_copy:` — extra transition columns to carry forward from the
713
+ previous transition onto the new one. The parent foreign key and `sort_key`
714
+ are always carried. There's no default, so you have to choose explicitly.
715
+ `attributes_per_id:` overrides those carried values for individual parents,
716
+ and may only name columns listed in `attributes_to_copy` (anything else raises
717
+ `Statesman::BulkTransition::ValidationError`).
718
+
719
+ If you pass a block, it is called inside the same transaction with an
720
+ `ActiveRecord::Relation` over the newly inserted transitions. The relation is
721
+ lazy, so the block decides what to read or write:
722
+
723
+ ```ruby
724
+ bulk.call!(order_ids) do |transitions|
725
+ rows = transitions.pluck(:order_id, :to_state)
726
+ AuditLog.insert_all(rows.map { |id, state| { order_id: id, state: state } })
727
+ end
728
+ ```
729
+
730
+ An exception raised in the block rolls the entire batch back. If a row
731
+ conflicts with the unique index on the transition table, `call!` raises
732
+ `Statesman::TransitionConflictError` and nothing is committed.
733
+
734
+ Two assumptions to be aware of: the transition table must not be an STI table,
735
+ and the parent must have a single-column primary key.
736
+
677
737
  ## Frequently Asked Questions
678
738
 
679
739
  ### Storing the state on the model object
data/Rakefile CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
+ require "open3"
4
5
  require "rspec/core/rake_task"
5
6
 
6
7
  RSpec::Core::RakeTask.new(:spec) do |task|
@@ -13,4 +14,34 @@ RSpec::Core::RakeTask.new(:spec) do |task|
13
14
  end
14
15
  end
15
16
 
16
- task default: :spec
17
+ DATABASES = %w[sqlite postgres mysql].freeze
18
+ RSPEC_COMMAND = %w[bundle exec rspec].freeze
19
+
20
+ # DB_IMAGE and DATABASE_URL are cleared so each run gets its own database rather than all of them
21
+ # sharing whichever one the caller's environment points at.
22
+ database_env = ->(db) { { "DB" => (db unless db == "sqlite"), "DB_IMAGE" => nil, "DATABASE_URL" => nil } }
23
+
24
+ namespace :spec do
25
+ DATABASES.each do |db|
26
+ desc "Run specs against #{db}"
27
+ task(db) { sh(database_env.call(db), *RSPEC_COMMAND) }
28
+ end
29
+
30
+ desc "Run specs against all databases in parallel"
31
+ task :all do
32
+ output_lock = Mutex.new
33
+
34
+ results = DATABASES.map do |db|
35
+ Thread.new do
36
+ output, status = Open3.capture2e(database_env.call(db), *RSPEC_COMMAND)
37
+ output_lock.synchronize { puts "==> #{db}", output }
38
+ [db, status.success?]
39
+ end
40
+ end.map(&:value)
41
+
42
+ failed = results.reject { |_, success| success }.map(&:first)
43
+ abort "Specs failed for: #{failed.join(', ')}" if failed.any?
44
+ end
45
+ end
46
+
47
+ task default: "spec:all"
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statesman
4
+ # Bulk, race-safe state transitions for ActiveRecord (PostgreSQL, MySQL, SQLite).
5
+ #
6
+ # Statesman's Machine#transition_to! handles one parent at a time: a flip UPDATE on
7
+ # the current `most_recent` transition, an INSERT for the new one, and (if the model
8
+ # called configure_cached_current_state) an UPDATE on the parent's cached column -
9
+ # each inside its own transaction, with guards/callbacks firing per record.
10
+ #
11
+ # BulkTransition performs the same mechanical write for many parents in a single
12
+ # transaction: one `UPDATE ... RETURNING` to flip+lock the old rows (on adapters without
13
+ # RETURNING: a `SELECT ... FOR UPDATE` then an `UPDATE`), one `insert_all!` for the new
14
+ # ones, and one bulk cached_current_state UPDATE if configured. This trades
15
+ # per-record guards/callbacks/AR-validations for an order-of-magnitude fewer round
16
+ # trips on large batches.
17
+ #
18
+ # Deliberately NOT run: Machine guards, before/after/after_commit callbacks, and AR
19
+ # validations/callbacks on the transition model. Callers that need equivalent side
20
+ # effects must reproduce them, set-based, in the block passed to #call!.
21
+ #
22
+ # Like the single-transition path, "not current" is written as NULL when the
23
+ # `most_recent` column is nullable (databases without partial indexes, e.g. MySQL, need
24
+ # this for the unique (parent, most_recent) index) and false otherwise. On MySQL the
25
+ # new rows are inserted "not current" and then marked current in a separate UPDATE, to
26
+ # avoid the next-key locks an INSERT of `most_recent = true` takes (see
27
+ # Statesman::Adapters::ActiveRecord#create_transition).
28
+ #
29
+ # Assumption (documented, not enforced): the transition table is not an STI table.
30
+ #
31
+ # A later version may add guard/callback execution (falling back to a plain per-item
32
+ # #transition_to! loop where needed) - this is the proven,
33
+ # independent mechanical core that such a version would sit on top of.
34
+ class BulkTransition
35
+ class ValidationError < StandardError; end
36
+
37
+ SORT_KEY_INCREMENT = 10
38
+
39
+ def initialize(model_class:, machine_class:, from:, to:)
40
+ @model_class = model_class
41
+ @from = from.to_s
42
+ @to = to.to_s
43
+ @transition_class = model_class.transition_class
44
+
45
+ machine_class.validate_from_and_to_state(@from, @to)
46
+ end
47
+
48
+ # @param parent_ids [Array<String, Integer>] primary keys of the parents to
49
+ # transition. Parents not currently in `from` state are silently skipped - this
50
+ # is what makes the batch idempotent and safe to re-run.
51
+ # @param metadata [Hash] applied uniformly to every new transition row in the batch,
52
+ # unless overridden per-parent by metadata_per_id.
53
+ # @param attributes_to_copy [Array<String, Symbol>] extra transition-row columns to
54
+ # carry forward from the flipped row onto the new one, beyond the parent FK and
55
+ # sort_key, which are always carried. No default: the caller must explicitly audit
56
+ # which mutable columns their own after_transition callbacks depend on.
57
+ # @param metadata_per_id [Hash{String, Integer => Hash}, nil] per-parent metadata
58
+ # overrides, keyed by parent id (matched by its string form, so 1 and "1" are the
59
+ # same parent), merged onto (and taking precedence over) the uniform `metadata` for
60
+ # that parent only. Parents absent from this hash just get the uniform `metadata`.
61
+ # @param attributes_per_id [Hash{String, Integer => Hash}, nil] per-parent overrides,
62
+ # keyed like metadata_per_id, for attributes_to_copy columns only (anything else
63
+ # raises ValidationError), merged onto (and taking precedence over) the value
64
+ # carried forward from that parent's own flipped row - for a value that can't be
65
+ # derived from the previous transition (e.g. a value looked up from an
66
+ # association the caller already has loaded).
67
+ # @param block [Proc, nil] optional, called with an ActiveRecord::Relation over the
68
+ # newly inserted transitions, inside the same transaction, after rows are flipped
69
+ # and inserted (and after the cached_current_state bulk update, if any). The
70
+ # relation is lazy: the block decides what to read (e.g. `pluck`) or write. An
71
+ # exception here rolls back the entire batch - nothing is committed.
72
+ # @return [Array] parent ids that were actually transitioned.
73
+ def call!(
74
+ parent_ids, metadata: {}, attributes_to_copy: [],
75
+ metadata_per_id: nil, attributes_per_id: nil, &block
76
+ )
77
+ return [] if parent_ids.empty?
78
+
79
+ attributes_to_copy = attributes_to_copy.to_set(&:to_s)
80
+ validate_attribute_names!(attributes_to_copy)
81
+ metadata_per_id = normalize_per_id(metadata_per_id)
82
+ attributes_per_id = normalize_per_id(attributes_per_id)
83
+ validate_attributes_per_id!(attributes_per_id, attributes_to_copy)
84
+
85
+ now = Time.current
86
+ transitioned_ids = []
87
+
88
+ ActiveRecord::Base.transaction(requires_new: true) do
89
+ flipped_rows = flip_most_recent!(parent_ids, now, attributes_to_copy)
90
+ next if flipped_rows.empty?
91
+
92
+ primary_keys, transitioned_ids = insert_new_transitions!(
93
+ flipped_rows, metadata, attributes_to_copy, now, metadata_per_id, attributes_per_id
94
+ ).transpose
95
+ inserted = transition_class.where(transition_class.primary_key => primary_keys)
96
+
97
+ update_cached_current_state!(transitioned_ids)
98
+
99
+ block&.call(inserted)
100
+ end
101
+
102
+ transitioned_ids
103
+ end
104
+
105
+ private
106
+
107
+ attr_reader :model_class, :from, :to, :transition_class
108
+
109
+ def mysql_gaplock_protection?
110
+ Statesman.mysql_gaplock_protection?(transition_class.connection)
111
+ end
112
+
113
+ # Matches the single-transition path: NULL when the column is nullable (needed for a
114
+ # unique (parent, most_recent) index on databases without partial indexes), else false.
115
+ def not_most_recent_value
116
+ transition_class.columns_hash["most_recent"].null == false ? false : nil
117
+ end
118
+
119
+ # PostgreSQL can flip and return rows in one `UPDATE ... RETURNING` statement and
120
+ # recover inserted keys from `INSERT ... RETURNING`. Other adapters (MySQL, SQLite)
121
+ # take a portable path built from `SELECT ... FOR UPDATE` + `UPDATE` by primary key.
122
+ def returning_supported?
123
+ transition_class.connection.adapter_name.casecmp("postgresql").zero?
124
+ end
125
+
126
+ def validate_attribute_names!(attributes_to_copy)
127
+ unknown = attributes_to_copy - transition_class.column_names
128
+ return if unknown.empty?
129
+
130
+ raise ValidationError, "Unknown #{transition_class.name} column(s): #{unknown.join(', ')}"
131
+ end
132
+
133
+ # Overrides are matched against ids read back from the database, whose type (Integer,
134
+ # String UUID) the caller needn't know - so both sides are compared as strings. Inner
135
+ # keys become strings too, because the rows being inserted are string-keyed.
136
+ def normalize_per_id(per_id)
137
+ per_id&.to_h { |id, overrides| [id.to_s, overrides.transform_keys(&:to_s)] }
138
+ end
139
+
140
+ # Every row in one insert_all! must have the same keys, and an override for a column
141
+ # that isn't carried forward would add a key to only some rows.
142
+ def validate_attributes_per_id!(attributes_per_id, attributes_to_copy)
143
+ return if attributes_per_id.nil?
144
+
145
+ unknown = attributes_per_id.values.flat_map(&:keys).to_set - attributes_to_copy
146
+ return if unknown.empty?
147
+
148
+ raise ValidationError, "attributes_per_id column(s) not in attributes_to_copy: #{unknown.to_a.join(', ')}"
149
+ end
150
+
151
+ def parent_reflection
152
+ @parent_reflection ||= Statesman::Adapters::ActiveRecordQueries.
153
+ transition_reflection_for(model_class, transition_class)
154
+ end
155
+
156
+ def parent_foreign_key
157
+ @parent_foreign_key ||= parent_reflection.foreign_key
158
+ end
159
+
160
+ def parent_primary_key
161
+ @parent_primary_key ||= parent_reflection.active_record_primary_key
162
+ end
163
+
164
+ # Flips the `from`-state transitions to not-most-recent, returning the flipped
165
+ # rows (including their pre-flip sort_key) via RETURNING. Under Postgres Read
166
+ # Committed, a concurrent writer that commits a transition on one of these parents
167
+ # between this statement starting and acquiring its row lock causes the WHERE
168
+ # clause to re-evaluate against the latest committed row on wake - so that parent
169
+ # is silently excluded here, never corrupted.
170
+ def flip_most_recent!(parent_ids, now, attributes_to_copy)
171
+ if returning_supported?
172
+ flip_with_returning!(parent_ids, now, attributes_to_copy)
173
+ else
174
+ flip_with_lock!(parent_ids, now, attributes_to_copy)
175
+ end
176
+ end
177
+
178
+ def flip_with_returning!(parent_ids, now, attributes_to_copy)
179
+ # Not `RETURNING *`: only the carried columns are used afterwards, and a wider
180
+ # result set is costly on large batches.
181
+ returning = carried_columns(attributes_to_copy).
182
+ map { |column| transition_class.connection.quote_column_name(column) }.
183
+ join(", ")
184
+
185
+ # Arel has no RETURNING support, so that one clause is appended to the compiled
186
+ # statement; everything else (quoting, binds, IN list) is built by Arel.
187
+ sql = "#{flip_update_manager(parent_ids, now).to_sql} RETURNING #{returning}"
188
+
189
+ transition_class.connection.exec_query(sql).to_a
190
+ end
191
+
192
+ def flip_update_manager(parent_ids, now)
193
+ table = transition_class.arel_table
194
+
195
+ Arel::UpdateManager.new.tap do |update|
196
+ update.table(table)
197
+ update.set([[table[:most_recent], not_most_recent_value], [table[:updated_at], now]])
198
+ update.where(
199
+ table[:to_state].eq(from).
200
+ and(table[:most_recent].eq(true)).
201
+ and(table[parent_foreign_key].in(parent_ids)),
202
+ )
203
+ end
204
+ end
205
+
206
+ # Portable equivalent of the above: lock the matching rows, then flip them by primary
207
+ # key. The locking read always sees the latest committed row (MySQL REPEATABLE READ
208
+ # included), so a parent that a concurrent writer already moved on is excluded. SQLite
209
+ # has no row locks, but serialises writers at the database level.
210
+ def flip_with_lock!(parent_ids, now, attributes_to_copy)
211
+ columns = carried_columns(attributes_to_copy)
212
+ primary_key = transition_class.primary_key
213
+
214
+ locked = transition_class.
215
+ where(to_state: from, most_recent: true, parent_foreign_key => parent_ids).
216
+ lock.
217
+ pluck(primary_key, *columns)
218
+ return [] if locked.empty?
219
+
220
+ transition_class.where(primary_key => locked.map(&:first)).
221
+ update_all(most_recent: not_most_recent_value, updated_at: now)
222
+
223
+ locked.map { |_pk, *values| columns.zip(values).to_h }
224
+ end
225
+
226
+ def carried_columns(attributes_to_copy)
227
+ Set[parent_foreign_key, "sort_key"] | attributes_to_copy
228
+ end
229
+
230
+ def insert_new_transitions!(
231
+ flipped_rows, metadata, attributes_to_copy, now, metadata_per_id, attributes_per_id
232
+ )
233
+ carried = carried_columns(attributes_to_copy)
234
+ include_from_state = transition_class.has_attribute?(:from_state)
235
+ most_recent = mysql_gaplock_protection? ? not_most_recent_value : true
236
+
237
+ rows = flipped_rows.map do |row|
238
+ id = row[parent_foreign_key]
239
+ row_metadata = merge_per_id(metadata, metadata_per_id, id)
240
+ row_overrides = merge_per_id({}, attributes_per_id, id)
241
+
242
+ new_row = row.slice(*carried).merge(row_overrides).merge(
243
+ "to_state" => to,
244
+ "metadata" => row_metadata,
245
+ "most_recent" => most_recent,
246
+ "sort_key" => row["sort_key"].to_i + SORT_KEY_INCREMENT,
247
+ "created_at" => now,
248
+ "updated_at" => now,
249
+ )
250
+ new_row["from_state"] = from if include_from_state
251
+ new_row
252
+ end
253
+
254
+ insert_rows!(rows)
255
+ rescue ActiveRecord::RecordNotUnique => e
256
+ # One conflicting row aborts the whole batch - this is an accepted trade-off,
257
+ # not a silent gap: every real writer (Statesman's own single-transition path, and
258
+ # this class) follows the flip-then-insert convention, so a collision here means
259
+ # something outside that convention wrote a most_recent row directly.
260
+ raise TransitionConflictError, e.message
261
+ end
262
+
263
+ # Inserts the rows and returns [primary key, parent foreign key] pairs for them, so the
264
+ # caller can both build a relation and know which parents moved without another query.
265
+ # Under MySQL gap-lock protection the rows are also marked most recent here, so
266
+ # whatever reads them afterwards sees the final state.
267
+ def insert_rows!(rows)
268
+ inserted = insert_and_fetch_keys!(rows)
269
+ mark_most_recent!(inserted.map(&:first)) if mysql_gaplock_protection?
270
+ inserted
271
+ end
272
+
273
+ def insert_and_fetch_keys!(rows)
274
+ if returning_supported?
275
+ return transition_class.insert_all!(rows, returning: [transition_class.primary_key, parent_foreign_key]).rows
276
+ end
277
+
278
+ transition_class.insert_all!(rows)
279
+ fetch_inserted_keys(rows)
280
+ end
281
+
282
+ # Finds the rows just inserted by (parent, sort_key): the unique index Statesman
283
+ # requires on those columns makes that pair unambiguous, whatever state the rows are in.
284
+ def fetch_inserted_keys(inserted)
285
+ sort_keys = inserted.map { |row| row["sort_key"] }.uniq
286
+ expected = inserted.to_set { |row| [row[parent_foreign_key], row["sort_key"]] }
287
+
288
+ transition_class.
289
+ where(parent_foreign_key => inserted.map { |row| row[parent_foreign_key] }, sort_key: sort_keys).
290
+ pluck(transition_class.primary_key, parent_foreign_key, "sort_key").
291
+ select { |_pk, fk, sort_key| expected.include?([fk, sort_key]) }.
292
+ map { |pk, fk, _sort_key| [pk, fk] }
293
+ end
294
+
295
+ # Second half of the MySQL gap-lock protection: the old rows were already flipped, so
296
+ # this only ever sets one row per parent to true and cannot trip the unique index.
297
+ def mark_most_recent!(primary_keys)
298
+ transition_class.where(transition_class.primary_key => primary_keys).
299
+ update_all(most_recent: true)
300
+ end
301
+
302
+ # Merges a per-parent override hash (see normalize_per_id) onto a base hash, for one
303
+ # parent only. The override, where present, wins key-by-key.
304
+ def merge_per_id(base, per_id, id)
305
+ return base if per_id.nil? || !per_id.key?(id.to_s)
306
+
307
+ base.merge(per_id.fetch(id.to_s))
308
+ end
309
+
310
+ def update_cached_current_state!(transitioned_ids)
311
+ return if transitioned_ids.empty?
312
+ return unless model_class.respond_to?(:cached_state_column_name)
313
+
314
+ column = model_class.cached_state_column_name
315
+ attrs = { column => to }
316
+ attrs[:updated_at] = Time.current if model_class.cached_current_state_touch_updated_at?
317
+
318
+ model_class.where(parent_primary_key => transitioned_ids).update_all(attrs)
319
+ end
320
+ end
321
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statesman
4
- VERSION = "13.3.0"
4
+ VERSION = "13.4.0"
5
5
  end
data/lib/statesman.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Statesman
4
4
  autoload :Config, "statesman/config"
5
5
  autoload :Machine, "statesman/machine"
6
+ autoload :BulkTransition, "statesman/bulk_transition"
6
7
  autoload :Callback, "statesman/callback"
7
8
  autoload :Guard, "statesman/guard"
8
9
  autoload :Utils, "statesman/utils"
data/spec/spec_helper.rb CHANGED
@@ -32,11 +32,18 @@ RSpec.configure do |config|
32
32
  else
33
33
  current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
34
34
 
35
+ database_url = ENV.fetch("DATABASE_URL") do
36
+ if (db = ENV["DB"].presence)
37
+ require "support/database_container"
38
+ DatabaseContainer.start(db, image: ENV["DB_IMAGE"].presence)
39
+ end
40
+ end
41
+
35
42
  # We have to parse this to a hash since ActiveRecord::Base.configurations
36
43
  # will only consider a single URL config.
37
- url_config = if ENV["DATABASE_URL"]
44
+ url_config = if database_url
38
45
  ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.
39
- new(ENV["DATABASE_URL"]).to_hash.merge({ sslmode: "disable" })
46
+ new(database_url).to_hash.merge({ sslmode: "disable" })
40
47
  end
41
48
 
42
49
  db_config = {
@@ -15,9 +15,7 @@ describe Statesman::Adapters::ActiveRecord, :active_record do
15
15
  Statesman.configure do
16
16
  # Rubocop requires described_class to be used, but this block
17
17
  # is instance_eval'd and described_class won't be defined
18
- # rubocop:disable RSpec/DescribedClass
19
- storage_adapter(Statesman::Adapters::ActiveRecord)
20
- # rubocop:enable RSpec/DescribedClass
18
+ storage_adapter(Statesman::Adapters::ActiveRecord) # rubocop:disable RSpec/DescribedClass
21
19
  end
22
20
  end
23
21
 
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe Statesman::BulkTransition, :active_record do
6
+ before do
7
+ # The shared MyActiveRecordModel fixture is wired via the older
8
+ # `include ActiveRecordQueries[...]` form, which doesn't define `.transition_class`.
9
+ # BulkTransition relies on the newer `configure_state_machine` convention (the same
10
+ # one `configure_cached_current_state` requires), so configure it here once.
11
+ unless MyActiveRecordModel.respond_to?(:transition_class)
12
+ MyActiveRecordModel.extend(Statesman::Adapters::TypeSafeActiveRecordQueries)
13
+ MyActiveRecordModel.configure_state_machine(
14
+ transition_class: MyActiveRecordModelTransition, initial_state: :initial,
15
+ )
16
+ end
17
+
18
+ unless MyActiveRecordModel.respond_to?(:cached_state_column_name)
19
+ MyActiveRecordModel.include(Statesman::Adapters::ConfigureCachedCurrentState)
20
+ MyActiveRecordModel.configure_cached_current_state
21
+ end
22
+
23
+ prepare_model_table
24
+ prepare_transitions_table
25
+
26
+ # Other specs redefine MyActiveRecordModel.transition_class without restoring it, so
27
+ # pin it here to stay independent of spec ordering.
28
+ allow(MyActiveRecordModel).to receive(:transition_class).
29
+ and_return(MyActiveRecordModelTransition)
30
+
31
+ # Add a free business column (not one of AR's "magic" created_on/updated_on/etc.
32
+ # timestamp columns, which insert_all auto-populates regardless of row content) so
33
+ # attributes_to_copy has something meaningful to carry forward in this spec.
34
+ ActiveRecord::Base.connection.execute(
35
+ "ALTER TABLE my_active_record_model_transitions ADD COLUMN extra_data text",
36
+ )
37
+ MyActiveRecordModelTransition.reset_column_information
38
+ end
39
+
40
+ let(:machine_class) { MyStateMachine }
41
+
42
+ # How a non-current row's most_recent is stored: NULL when the column is nullable
43
+ # (needed for the unique index on databases without partial indexes, e.g. MySQL),
44
+ # false otherwise - same as the single-transition path.
45
+ def not_most_recent_value
46
+ MyActiveRecordModelTransition.columns_hash["most_recent"].null ? nil : false
47
+ end
48
+
49
+ def create_model(initial_state: "initial", extra_data: nil)
50
+ model = MyActiveRecordModel.create!
51
+ model.my_active_record_model_transitions.create!(
52
+ to_state: initial_state, sort_key: 10, most_recent: true, metadata: {},
53
+ extra_data: extra_data
54
+ )
55
+ model
56
+ end
57
+
58
+ def last_transition_for(id)
59
+ MyActiveRecordModel.find(id).my_active_record_model_transitions.order(:sort_key).last
60
+ end
61
+
62
+ describe "#call!" do
63
+ it "transitions only the parents currently in the from state" do
64
+ in_initial = Array.new(3) { create_model.id }
65
+ in_succeeded = create_model(initial_state: "succeeded").id
66
+
67
+ bulk = described_class.new(
68
+ model_class: MyActiveRecordModel, machine_class: machine_class,
69
+ from: :initial, to: :succeeded
70
+ )
71
+ transitioned = bulk.call!(in_initial + [in_succeeded], metadata: { "foo" => "bar" })
72
+
73
+ expect(transitioned).to match_array(in_initial)
74
+ end
75
+
76
+ it "raises TransitionConflictError and rolls the whole batch back on a conflicting row" do
77
+ ok = create_model.id
78
+ conflicted = create_model.id
79
+ # An out-of-band row occupying the sort_key the new transition would take.
80
+ MyActiveRecordModelTransition.create!(
81
+ my_active_record_model_id: conflicted, to_state: "failed", sort_key: 20,
82
+ most_recent: not_most_recent_value, metadata: {}
83
+ )
84
+ bulk = described_class.new(
85
+ model_class: MyActiveRecordModel, machine_class: machine_class,
86
+ from: :initial, to: :succeeded
87
+ )
88
+
89
+ expect { bulk.call!([ok, conflicted]) }.to raise_error(Statesman::TransitionConflictError)
90
+
91
+ expect(MyActiveRecordModelTransition.where(my_active_record_model_id: [ok, conflicted], most_recent: true).
92
+ pluck(:to_state)).to eq(%w[initial initial])
93
+ end
94
+
95
+ it "marks flipped rows as not current the same way the single path does" do
96
+ ids = Array.new(2) { create_model.id }
97
+ bulk = described_class.new(
98
+ model_class: MyActiveRecordModel, machine_class: machine_class,
99
+ from: :initial, to: :succeeded
100
+ )
101
+ bulk.call!(ids)
102
+
103
+ old_rows = MyActiveRecordModelTransition.where(my_active_record_model_id: ids, to_state: "initial")
104
+
105
+ expect(old_rows.map(&:most_recent)).to all(eq(not_most_recent_value))
106
+ expect(MyActiveRecordModelTransition.where(my_active_record_model_id: ids, most_recent: true).count).
107
+ to eq(ids.size)
108
+ end
109
+
110
+ it "applies the metadata and most_recent flag to each transitioned parent" do
111
+ in_initial = Array.new(3) { create_model.id }
112
+ bulk = described_class.new(
113
+ model_class: MyActiveRecordModel, machine_class: machine_class,
114
+ from: :initial, to: :succeeded
115
+ )
116
+ bulk.call!(in_initial, metadata: { "foo" => "bar" })
117
+
118
+ MyActiveRecordModel.where(id: in_initial).each do |model|
119
+ last = model.my_active_record_model_transitions.order(:sort_key).last
120
+ expect(last.to_state).to eq("succeeded")
121
+ expect(last.most_recent).to be(true)
122
+ expect(last.metadata).to eq({ "foo" => "bar" })
123
+ end
124
+ end
125
+
126
+ it "leaves parents not currently in the from state untouched" do
127
+ in_succeeded = create_model(initial_state: "succeeded").id
128
+ bulk = described_class.new(
129
+ model_class: MyActiveRecordModel, machine_class: machine_class,
130
+ from: :initial, to: :succeeded
131
+ )
132
+ bulk.call!([in_succeeded], metadata: { "foo" => "bar" })
133
+
134
+ expect(last_transition_for(in_succeeded).sort_key).to eq(10)
135
+ end
136
+
137
+ it "is idempotent - re-running skips parents no longer in the from state" do
138
+ ids = Array.new(2) { create_model.id }
139
+ bulk = described_class.new(
140
+ model_class: MyActiveRecordModel, machine_class: machine_class,
141
+ from: :initial, to: :succeeded
142
+ )
143
+
144
+ first_run = bulk.call!(ids)
145
+ second_run = bulk.call!(ids)
146
+
147
+ expect(first_run).to match_array(ids)
148
+ expect(second_run).to eq([])
149
+ end
150
+
151
+ it "flips the old most_recent row off and sets sort_key = old + 10" do
152
+ id = create_model.id
153
+ bulk = described_class.new(
154
+ model_class: MyActiveRecordModel, machine_class: machine_class,
155
+ from: :initial, to: :succeeded
156
+ )
157
+ bulk.call!([id])
158
+
159
+ transitions = MyActiveRecordModel.find(id).
160
+ my_active_record_model_transitions.order(:sort_key)
161
+ expect(transitions.map(&:most_recent)).to eq([not_most_recent_value, true])
162
+ expect(transitions.map(&:sort_key)).to eq([10, 20])
163
+ end
164
+
165
+ it "carries forward only explicitly requested attributes_to_copy" do
166
+ id = create_model(extra_data: "keep-me").id
167
+ bulk = described_class.new(
168
+ model_class: MyActiveRecordModel, machine_class: machine_class,
169
+ from: :initial, to: :succeeded
170
+ )
171
+ bulk.call!([id], attributes_to_copy: ["extra_data"])
172
+
173
+ expect(last_transition_for(id).extra_data).to eq("keep-me")
174
+ end
175
+
176
+ it "does not carry forward attributes that weren't requested" do
177
+ id = create_model(extra_data: "should-not-persist").id
178
+ bulk = described_class.new(
179
+ model_class: MyActiveRecordModel, machine_class: machine_class,
180
+ from: :initial, to: :succeeded
181
+ )
182
+ bulk.call!([id]) # no attributes_to_copy
183
+
184
+ expect(last_transition_for(id).extra_data).to be_nil
185
+ end
186
+
187
+ describe "flip query RETURNING clause" do
188
+ def flip_sql_for(bulk, id, **options)
189
+ statements = []
190
+ subscriber = ->(*, payload) do
191
+ statements << payload[:sql] if /\AUPDATE\s+"?my_active_record_model_transitions/i.match?(payload[:sql])
192
+ end
193
+ ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
194
+ bulk.call!([id], **options)
195
+ end
196
+ statements.find { |sql| sql.include?(" RETURNING ") }
197
+ end
198
+
199
+ let(:bulk) do
200
+ described_class.new(
201
+ model_class: MyActiveRecordModel, machine_class: machine_class,
202
+ from: :initial, to: :succeeded
203
+ )
204
+ end
205
+
206
+ it "returns only the carried columns, not *" do
207
+ skip "RETURNING is PostgreSQL-only" unless postgres? # rubocop:disable RSpec/Pending
208
+ id = create_model(extra_data: "x").id
209
+ sql = flip_sql_for(bulk, id, attributes_to_copy: ["extra_data"])
210
+ returning = sql[/RETURNING (.*)\z/m, 1]
211
+
212
+ expect(sql).to_not include("RETURNING *")
213
+ expect(returning.split(", ")).to contain_exactly(
214
+ '"my_active_record_model_id"', '"sort_key"', '"extra_data"'
215
+ )
216
+ end
217
+
218
+ it "does not return columns that were not requested" do
219
+ skip "RETURNING is PostgreSQL-only" unless postgres? # rubocop:disable RSpec/Pending
220
+ id = create_model(extra_data: "x").id
221
+ sql = flip_sql_for(bulk, id, attributes_to_copy: [])
222
+
223
+ expect(sql[/RETURNING (.*)\z/m, 1]).to_not include("extra_data")
224
+ end
225
+ end
226
+
227
+ it "raises ValidationError for unknown attributes_to_copy columns" do
228
+ id = create_model.id
229
+ bulk = described_class.new(
230
+ model_class: MyActiveRecordModel, machine_class: machine_class,
231
+ from: :initial, to: :succeeded
232
+ )
233
+
234
+ expect do
235
+ bulk.call!([id], attributes_to_copy: ["not_a_real_column"])
236
+ end.to raise_error(Statesman::BulkTransition::ValidationError, /not_a_real_column/)
237
+ end
238
+
239
+ it "raises InvalidTransitionError for a from/to pair the machine doesn't allow" do
240
+ expect do
241
+ described_class.new(
242
+ model_class: MyActiveRecordModel, machine_class: machine_class,
243
+ from: :succeeded, to: :succeeded
244
+ )
245
+ end.to raise_error(Statesman::InvalidTransitionError)
246
+ end
247
+
248
+ it "runs the block inside the transaction and rolls back everything if it raises" do
249
+ id = create_model.id
250
+ bulk = described_class.new(
251
+ model_class: MyActiveRecordModel, machine_class: machine_class,
252
+ from: :initial, to: :succeeded
253
+ )
254
+
255
+ expect do
256
+ bulk.call!([id]) { |_rows| raise "boom" }
257
+ end.to raise_error("boom")
258
+
259
+ last = last_transition_for(id)
260
+ expect(last.to_state).to eq("initial")
261
+ expect(last.most_recent).to be(true)
262
+ end
263
+
264
+ it "matches per-parent overrides by the string form of the id" do
265
+ id = create_model(extra_data: "from-previous-row").id
266
+ bulk = described_class.new(
267
+ model_class: MyActiveRecordModel, machine_class: machine_class,
268
+ from: :initial, to: :succeeded
269
+ )
270
+
271
+ bulk.call!(
272
+ [id],
273
+ metadata_per_id: { id.to_s => { "origin" => "api" } },
274
+ attributes_to_copy: ["extra_data"],
275
+ attributes_per_id: { id.to_s => { extra_data: "looked-up-value" } },
276
+ )
277
+
278
+ expect(last_transition_for(id).metadata).to eq({ "origin" => "api" })
279
+ expect(last_transition_for(id).extra_data).to eq("looked-up-value")
280
+ end
281
+
282
+ it "raises ValidationError for attributes_per_id columns not in attributes_to_copy" do
283
+ id = create_model.id
284
+ bulk = described_class.new(
285
+ model_class: MyActiveRecordModel, machine_class: machine_class,
286
+ from: :initial, to: :succeeded
287
+ )
288
+
289
+ expect do
290
+ bulk.call!([id], attributes_per_id: { id => { "extra_data" => "x" } })
291
+ end.to raise_error(Statesman::BulkTransition::ValidationError, /extra_data/)
292
+ end
293
+
294
+ it "yields a relation over the inserted transitions to the block" do
295
+ id = create_model.id
296
+ bulk = described_class.new(
297
+ model_class: MyActiveRecordModel, machine_class: machine_class,
298
+ from: :initial, to: :succeeded
299
+ )
300
+
301
+ yielded = nil
302
+ bulk.call!([id]) { |transitions| yielded = transitions.pluck(:my_active_record_model_id, :to_state) }
303
+
304
+ expect(yielded).to eq([[id, "succeeded"]])
305
+ end
306
+
307
+ it "invokes a callback object's #call via normal method dispatch, not bare yield" do
308
+ id = create_model.id
309
+ bulk = described_class.new(
310
+ model_class: MyActiveRecordModel, machine_class: machine_class,
311
+ from: :initial, to: :succeeded
312
+ )
313
+ callback = ->(_rows) {}
314
+
315
+ expect(callback).to receive(:call).once.and_call_original
316
+
317
+ bulk.call!([id], &callback)
318
+ end
319
+
320
+ it "returns an empty array and does nothing for an empty id list" do
321
+ bulk = described_class.new(
322
+ model_class: MyActiveRecordModel, machine_class: machine_class,
323
+ from: :initial, to: :succeeded
324
+ )
325
+ expect(bulk.call!([])).to eq([])
326
+ end
327
+
328
+ it "overrides metadata per-parent via metadata_per_id, falling back to the uniform metadata" do
329
+ overridden_id = create_model.id
330
+ default_id = create_model.id
331
+ bulk = described_class.new(
332
+ model_class: MyActiveRecordModel, machine_class: machine_class,
333
+ from: :initial, to: :succeeded
334
+ )
335
+
336
+ bulk.call!(
337
+ [overridden_id, default_id],
338
+ metadata: { "origin" => "gocardless" },
339
+ metadata_per_id: { overridden_id => { "origin" => "api" } },
340
+ )
341
+
342
+ expect(last_transition_for(overridden_id).metadata).to eq({ "origin" => "api" })
343
+ expect(last_transition_for(default_id).metadata).to eq({ "origin" => "gocardless" })
344
+ end
345
+
346
+ it "overrides a copied attribute per-parent via attributes_per_id" do
347
+ overridden_id = create_model(extra_data: "from-previous-row").id
348
+ default_id = create_model(extra_data: "from-previous-row").id
349
+ bulk = described_class.new(
350
+ model_class: MyActiveRecordModel, machine_class: machine_class,
351
+ from: :initial, to: :succeeded
352
+ )
353
+
354
+ bulk.call!(
355
+ [overridden_id, default_id],
356
+ attributes_to_copy: ["extra_data"],
357
+ attributes_per_id: { overridden_id => { "extra_data" => "looked-up-value" } },
358
+ )
359
+
360
+ expect(last_transition_for(overridden_id).extra_data).to eq("looked-up-value")
361
+ expect(last_transition_for(default_id).extra_data).to eq("from-previous-row")
362
+ end
363
+
364
+ it "yields only the new transitions, with their final most_recent state" do
365
+ id = create_model.id
366
+ bulk = described_class.new(
367
+ model_class: MyActiveRecordModel, machine_class: machine_class,
368
+ from: :initial, to: :succeeded
369
+ )
370
+
371
+ yielded = nil
372
+ bulk.call!([id]) { |transitions| yielded = transitions.pluck(:to_state, :most_recent) }
373
+
374
+ expect(yielded).to eq([["succeeded", true]])
375
+ end
376
+ end
377
+
378
+ describe "cached_current_state integration" do
379
+ it "bulk-updates the cached column for every transitioned parent" do
380
+ ids = Array.new(2) { create_model.id }
381
+ bulk = described_class.new(
382
+ model_class: MyActiveRecordModel, machine_class: machine_class,
383
+ from: :initial, to: :succeeded
384
+ )
385
+ bulk.call!(ids)
386
+
387
+ MyActiveRecordModel.where(id: ids).each do |model|
388
+ expect(model.cached_current_state).to eq("succeeded")
389
+ end
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "testcontainers/mysql"
4
+ require "testcontainers/postgres"
5
+
6
+ # Starts a throwaway database in Docker so the suite can run against PostgreSQL or
7
+ # MySQL locally without a pre-provisioned server.
8
+ module DatabaseContainer
9
+ DEFAULT_IMAGES = {
10
+ "postgres" => "postgres:17",
11
+ "mysql" => "mysql:8.4",
12
+ }.freeze
13
+
14
+ def self.start(db, image: nil)
15
+ image ||= DEFAULT_IMAGES.fetch(db) do
16
+ raise ArgumentError, "Unsupported DB '#{db}', expected one of: " \
17
+ "#{DEFAULT_IMAGES.keys.join(', ')}"
18
+ end
19
+
20
+ container = build(db, image)
21
+ container.start
22
+ at_exit { container.remove(force: true) }
23
+
24
+ database_url(container)
25
+ end
26
+
27
+ def self.build(db, image)
28
+ case db
29
+ when "postgres" then Testcontainers::PostgresContainer.new(image)
30
+ when "mysql" then Testcontainers::MysqlContainer.new(image)
31
+ end
32
+ end
33
+
34
+ def self.database_url(container)
35
+ if container.is_a?(Testcontainers::MysqlContainer)
36
+ container.database_url(protocol: "mysql2")
37
+ else
38
+ container.database_url
39
+ end
40
+ end
41
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statesman
3
3
  version: !ruby/object:Gem::Version
4
- version: 13.3.0
4
+ version: 13.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless
@@ -25,6 +25,7 @@ files:
25
25
  - ".rubocop.yml"
26
26
  - ".rubocop_todo.yml"
27
27
  - ".ruby-version"
28
+ - ".tool-versions"
28
29
  - CHANGELOG.md
29
30
  - CONTRIBUTING.md
30
31
  - Gemfile
@@ -47,6 +48,7 @@ files:
47
48
  - lib/statesman/adapters/memory.rb
48
49
  - lib/statesman/adapters/memory_transition.rb
49
50
  - lib/statesman/adapters/type_safe_active_record_queries.rb
51
+ - lib/statesman/bulk_transition.rb
50
52
  - lib/statesman/callback.rb
51
53
  - lib/statesman/config.rb
52
54
  - lib/statesman/exceptions.rb
@@ -70,6 +72,7 @@ files:
70
72
  - spec/statesman/adapters/memory_transition_spec.rb
71
73
  - spec/statesman/adapters/shared_examples.rb
72
74
  - spec/statesman/adapters/type_safe_active_record_queries_spec.rb
75
+ - spec/statesman/bulk_transition_spec.rb
73
76
  - spec/statesman/callback_spec.rb
74
77
  - spec/statesman/config_spec.rb
75
78
  - spec/statesman/exceptions_spec.rb
@@ -77,6 +80,7 @@ files:
77
80
  - spec/statesman/machine_spec.rb
78
81
  - spec/statesman/utils_spec.rb
79
82
  - spec/support/active_record.rb
83
+ - spec/support/database_container.rb
80
84
  - spec/support/exactly_query_databases.rb
81
85
  - spec/support/generators_shared_examples.rb
82
86
  - statesman.gemspec