stepper_motor 0.1.12 → 0.1.15

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.
@@ -0,0 +1,58 @@
1
+ class StepperMotorMigration005 < ActiveRecord::Migration[7.2]
2
+ def up
3
+ unless mysql?
4
+ say "Skipping migration as it is only used with MySQL (mysql2 or trilogy)"
5
+ return
6
+ end
7
+
8
+ # Add generated column that combines the state, type, hero_id, and hero_type
9
+ # The column will be NULL if any of the components is NULL
10
+ execute <<-SQL
11
+ ALTER TABLE stepper_motor_journeys
12
+ ADD COLUMN journey_uniq_col_generated VARCHAR(255) GENERATED ALWAYS AS (
13
+ CASE
14
+ WHEN state IN ('ready', 'performing', 'paused')
15
+ AND allow_multiple = 0
16
+ AND type IS NOT NULL
17
+ AND hero_id IS NOT NULL
18
+ AND hero_type IS NOT NULL
19
+ THEN CONCAT(type, ':', hero_id, ':', hero_type)
20
+ ELSE NULL
21
+ END
22
+ ) STORED
23
+ SQL
24
+
25
+ # Add unique index on the generated column with MySQL-specific name
26
+ add_index :stepper_motor_journeys, :journey_uniq_col_generated,
27
+ unique: true,
28
+ name: :idx_journeys_one_per_hero_mysql_generated
29
+
30
+ # Remove old indexes that include 'ready', 'performing', and 'paused' states
31
+ remove_index :stepper_motor_journeys, name: :idx_journeys_one_per_hero_with_paused
32
+ end
33
+
34
+ def down
35
+ unless mysql?
36
+ say "Skipping migration as it is only used with MySQL (mysql2 or trilogy)"
37
+ return
38
+ end
39
+
40
+ # Remove the generated column and its index
41
+ remove_index :stepper_motor_journeys, name: :idx_journeys_one_per_hero_mysql_generated
42
+ remove_column :stepper_motor_journeys, :journey_uniq_col_generated
43
+
44
+ # Recreate old indexes
45
+ quoted_false = connection.quote(false)
46
+ add_index :stepper_motor_journeys, [:type, :hero_id, :hero_type],
47
+ where: "allow_multiple = '#{quoted_false}' AND state IN ('ready', 'performing', 'paused')",
48
+ unique: true,
49
+ name: :idx_journeys_one_per_hero_with_paused
50
+ end
51
+
52
+ private
53
+
54
+ def mysql?
55
+ adapter = connection.adapter_name.downcase
56
+ adapter == "mysql2" || adapter == "trilogy"
57
+ end
58
+ end
@@ -10,8 +10,8 @@
10
10
  #
11
11
  # It's strongly recommended that you check this file into your version control system.
12
12
 
13
- ActiveRecord::Schema[7.2].define(version: 2025_05_28_141038) do
14
- create_table "stepper_motor_journeys", force: :cascade do |t|
13
+ ActiveRecord::Schema[7.2].define(version: 2025_06_09_221201) do
14
+ create_table "stepper_motor_journeys", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
15
15
  t.string "type", null: false
16
16
  t.string "state", default: "ready"
17
17
  t.string "hero_type"
@@ -25,12 +25,13 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_28_141038) do
25
25
  t.datetime "created_at", null: false
26
26
  t.datetime "updated_at", null: false
27
27
  t.string "idempotency_key"
28
+ t.virtual "journey_uniq_col_generated", type: :string, as: "case when `state` in ('ready','performing','paused') and `allow_multiple` = 0 and `type` is not null and `hero_id` is not null and `hero_type` is not null then concat(`type`,':',`hero_id`,':',`hero_type`) else NULL end", stored: true
28
29
  t.index ["hero_type", "hero_id"], name: "index_stepper_motor_journeys_on_hero_type_and_hero_id"
29
- t.index ["next_step_to_be_performed_at"], name: "index_stepper_motor_journeys_on_next_step_to_be_performed_at", where: "state = 'ready' /*application='Dummy'*/"
30
- t.index ["type", "hero_id", "hero_type"], name: "idx_journeys_one_per_hero_with_paused", unique: true, where: "allow_multiple = '0' AND state IN ('ready', 'performing', 'paused') /*application='Dummy'*/"
30
+ t.index ["journey_uniq_col_generated"], name: "idx_journeys_one_per_hero_mysql_generated", unique: true
31
+ t.index ["next_step_to_be_performed_at"], name: "index_stepper_motor_journeys_on_next_step_to_be_performed_at"
31
32
  t.index ["type", "hero_type", "hero_id"], name: "index_stepper_motor_journeys_on_type_and_hero_type_and_hero_id"
32
33
  t.index ["type"], name: "index_stepper_motor_journeys_on_type"
33
- t.index ["updated_at"], name: "index_stepper_motor_journeys_on_updated_at", where: "state = 'canceled' OR state = 'finished' /*application='Dummy'*/"
34
- t.index ["updated_at"], name: "stuck_journeys_index", where: "state = 'performing' /*application='Dummy'*/"
34
+ t.index ["updated_at"], name: "index_stepper_motor_journeys_on_updated_at"
35
+ t.index ["updated_at"], name: "stuck_journeys_index"
35
36
  end
36
37
  end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "minitest/mock"
5
+
6
+ class IfConditionTest < ActiveSupport::TestCase
7
+ include ActiveJob::TestHelper
8
+ include SideEffects::TestHelper
9
+ include StepperMotor::TestHelper
10
+
11
+ test "supports if: with symbol condition that returns true" do
12
+ journey_class = create_journey_subclass do
13
+ step :one, if: :should_run do
14
+ SideEffects.touch!("step executed")
15
+ end
16
+
17
+ def should_run
18
+ true
19
+ end
20
+ end
21
+
22
+ journey = journey_class.create!
23
+ assert_produced_side_effects("step executed") do
24
+ journey.perform_next_step!
25
+ end
26
+ assert journey.finished?
27
+ end
28
+
29
+ test "supports if: with symbol condition that returns false" do
30
+ journey_class = create_journey_subclass do
31
+ step :one, if: :should_run do
32
+ SideEffects.touch!("step executed")
33
+ end
34
+
35
+ step :two do
36
+ SideEffects.touch!("second step executed")
37
+ end
38
+
39
+ def should_run
40
+ false
41
+ end
42
+ end
43
+
44
+ journey = journey_class.create!
45
+ speedrun_journey(journey)
46
+ assert SideEffects.produced?("second step executed")
47
+ refute SideEffects.produced?("step executed")
48
+ end
49
+
50
+ test "supports if: with block condition that returns true" do
51
+ journey_class = create_journey_subclass do
52
+ step :one, if: -> { hero.present? } do
53
+ SideEffects.touch!("step executed")
54
+ end
55
+ end
56
+
57
+ journey = journey_class.create!(hero: create_journey_subclass.create!)
58
+ assert_produced_side_effects("step executed") do
59
+ journey.perform_next_step!
60
+ end
61
+ assert journey.finished?
62
+ end
63
+
64
+ test "supports if: with block condition that returns false" do
65
+ journey_class = create_journey_subclass do
66
+ step :one, if: -> { hero.nil? } do
67
+ SideEffects.touch!("step executed")
68
+ end
69
+
70
+ step :two do
71
+ SideEffects.touch!("second step executed")
72
+ end
73
+ end
74
+
75
+ journey = journey_class.create!(hero: create_journey_subclass.create!)
76
+ speedrun_journey(journey)
77
+ assert SideEffects.produced?("second step executed")
78
+ refute SideEffects.produced?("step executed")
79
+ end
80
+
81
+ test "supports if: with block condition that accesses journey instance variables" do
82
+ journey_class = create_journey_subclass do
83
+ step :one, if: -> { @condition_met } do
84
+ SideEffects.touch!("step executed")
85
+ end
86
+
87
+ step :two do
88
+ SideEffects.touch!("second step executed")
89
+ end
90
+
91
+ def initialize(*args)
92
+ super
93
+ @condition_met = false
94
+ end
95
+ end
96
+
97
+ journey = journey_class.create!
98
+ speedrun_journey(journey)
99
+ assert SideEffects.produced?("second step executed")
100
+ refute SideEffects.produced?("step executed")
101
+ end
102
+
103
+ test "supports if: with block condition that can be changed during journey execution" do
104
+ journey_class = create_journey_subclass do
105
+ step :one, if: -> { @condition_met } do
106
+ SideEffects.touch!("first step executed")
107
+ end
108
+
109
+ step :two do
110
+ SideEffects.touch!("second step executed")
111
+ @condition_met = true
112
+ end
113
+
114
+ step :three, if: -> { @condition_met } do
115
+ SideEffects.touch!("third step executed")
116
+ end
117
+
118
+ def initialize(*args)
119
+ super
120
+ @condition_met = false
121
+ end
122
+ end
123
+
124
+ journey = journey_class.create!
125
+ speedrun_journey(journey)
126
+ assert SideEffects.produced?("second step executed")
127
+ assert SideEffects.produced?("third step executed")
128
+ refute SideEffects.produced?("first step executed")
129
+ end
130
+
131
+ test "skips step when if: condition is false and continues to next step" do
132
+ journey_class = create_journey_subclass do
133
+ step :one, if: :false_condition do
134
+ SideEffects.touch!("first step executed")
135
+ end
136
+
137
+ step :two do
138
+ SideEffects.touch!("second step executed")
139
+ end
140
+
141
+ step :three do
142
+ SideEffects.touch!("third step executed")
143
+ end
144
+
145
+ def false_condition
146
+ false
147
+ end
148
+ end
149
+
150
+ journey = journey_class.create!
151
+ speedrun_journey(journey)
152
+ assert SideEffects.produced?("second step executed")
153
+ assert SideEffects.produced?("third step executed")
154
+ refute SideEffects.produced?("first step executed")
155
+ end
156
+
157
+ test "skips step when if: condition is false and finishes journey if no more steps" do
158
+ journey_class = create_journey_subclass do
159
+ step :one, if: :false_condition do
160
+ SideEffects.touch!("step executed")
161
+ end
162
+
163
+ def false_condition
164
+ false
165
+ end
166
+ end
167
+
168
+ journey = journey_class.create!
169
+ speedrun_journey(journey)
170
+ refute SideEffects.produced?("step executed")
171
+ end
172
+
173
+ test "supports if: with literal true" do
174
+ journey_class = create_journey_subclass do
175
+ step :one, if: true do
176
+ SideEffects.touch!("step executed")
177
+ end
178
+ end
179
+
180
+ journey = journey_class.create!
181
+ assert_produced_side_effects("step executed") do
182
+ journey.perform_next_step!
183
+ end
184
+ assert journey.finished?
185
+ end
186
+
187
+ test "supports if: with literal false" do
188
+ journey_class = create_journey_subclass do
189
+ step :one, if: false do
190
+ SideEffects.touch!("step executed")
191
+ end
192
+
193
+ step :two do
194
+ SideEffects.touch!("second step executed")
195
+ end
196
+ end
197
+
198
+ journey = journey_class.create!
199
+ speedrun_journey(journey)
200
+ assert SideEffects.produced?("second step executed")
201
+ refute SideEffects.produced?("step executed")
202
+ end
203
+
204
+ test "supports if: with literal false and finishes journey if no more steps" do
205
+ journey_class = create_journey_subclass do
206
+ step :one, if: false do
207
+ SideEffects.touch!("step executed")
208
+ end
209
+ end
210
+
211
+ journey = journey_class.create!
212
+ speedrun_journey(journey)
213
+ refute SideEffects.produced?("step executed")
214
+ end
215
+
216
+ test "defaults to true when if: is not specified" do
217
+ journey_class = create_journey_subclass do
218
+ step :one do
219
+ SideEffects.touch!("step executed")
220
+ end
221
+ end
222
+
223
+ journey = journey_class.create!
224
+ assert_produced_side_effects("step executed") do
225
+ journey.perform_next_step!
226
+ end
227
+ assert journey.finished?
228
+ end
229
+
230
+ test "treats nil as false in if condition" do
231
+ journey_class = create_journey_subclass do
232
+ step :one, if: nil do
233
+ SideEffects.touch!("step executed")
234
+ end
235
+
236
+ step :two do
237
+ SideEffects.touch!("second step executed")
238
+ end
239
+ end
240
+
241
+ journey = journey_class.create!
242
+ speedrun_journey(journey)
243
+ assert SideEffects.produced?("second step executed")
244
+ refute SideEffects.produced?("step executed")
245
+ end
246
+
247
+ test "treats nil as false and finishes journey if no more steps" do
248
+ journey_class = create_journey_subclass do
249
+ step :one, if: nil do
250
+ SideEffects.touch!("step executed")
251
+ end
252
+ end
253
+
254
+ journey = journey_class.create!
255
+ speedrun_journey(journey)
256
+ refute SideEffects.produced?("step executed")
257
+ end
258
+
259
+ test "raises ArgumentError when if: condition is neither symbol nor callable" do
260
+ assert_raises(ArgumentError) do
261
+ create_journey_subclass do
262
+ step :one, if: "not a symbol or callable" do
263
+ # noop
264
+ end
265
+ end
266
+ end
267
+ end
268
+
269
+ test "passes if: parameter to step definition" do
270
+ step_def = StepperMotor::Step.new(name: "a_step", seq: 1, on_exception: :reattempt!)
271
+ assert_if_parameter = ->(**options) {
272
+ assert options.key?(:if)
273
+ assert_equal :test_condition, options[:if]
274
+ # Return the original definition
275
+ step_def
276
+ }
277
+
278
+ StepperMotor::Step.stub :new, assert_if_parameter do
279
+ create_journey_subclass do
280
+ step :test_step, if: :test_condition do
281
+ # noop
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -6,6 +6,7 @@ require "minitest/mock"
6
6
  class StepDefinitionTest < ActiveSupport::TestCase
7
7
  include ActiveJob::TestHelper
8
8
  include SideEffects::TestHelper
9
+ include StepperMotor::TestHelper
9
10
 
10
11
  test "requires either a block or a name" do
11
12
  assert_raises(StepperMotor::StepConfigurationError) do
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stepper_motor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.12
4
+ version: 0.1.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-06-08 00:00:00.000000000 Z
10
+ date: 2025-06-20 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -94,6 +93,34 @@ dependencies:
94
93
  - - ">="
95
94
  - !ruby/object:Gem::Version
96
95
  version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: mysql2
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: pg
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
97
124
  - !ruby/object:Gem::Dependency
98
125
  name: rake
99
126
  requirement: !ruby/object:Gem::Requirement
@@ -206,6 +233,7 @@ files:
206
233
  - lib/generators/stepper_motor_migration_002.rb.erb
207
234
  - lib/generators/stepper_motor_migration_003.rb.erb
208
235
  - lib/generators/stepper_motor_migration_004.rb.erb
236
+ - lib/generators/stepper_motor_migration_005.rb.erb
209
237
  - lib/stepper_motor.rb
210
238
  - lib/stepper_motor/base_job.rb
211
239
  - lib/stepper_motor/cyclic_scheduler.rb
@@ -246,6 +274,9 @@ files:
246
274
  - test/dummy/config/application.rb
247
275
  - test/dummy/config/boot.rb
248
276
  - test/dummy/config/cable.yml
277
+ - test/dummy/config/database.mysql2.yml
278
+ - test/dummy/config/database.postgres.yml
279
+ - test/dummy/config/database.sqlite3.yml
249
280
  - test/dummy/config/database.yml
250
281
  - test/dummy/config/environment.rb
251
282
  - test/dummy/config/environments/development.rb
@@ -263,6 +294,7 @@ files:
263
294
  - test/dummy/db/migrate/20250525132524_stepper_motor_migration_002.rb
264
295
  - test/dummy/db/migrate/20250525132525_stepper_motor_migration_003.rb
265
296
  - test/dummy/db/migrate/20250528141038_stepper_motor_migration_004.rb
297
+ - test/dummy/db/migrate/20250609221201_stepper_motor_migration_005.rb
266
298
  - test/dummy/db/schema.rb
267
299
  - test/dummy/public/400.html
268
300
  - test/dummy/public/404.html
@@ -280,6 +312,7 @@ files:
280
312
  - test/stepper_motor/journey/exception_handling_test.rb
281
313
  - test/stepper_motor/journey/flow_control_test.rb
282
314
  - test/stepper_motor/journey/idempotency_test.rb
315
+ - test/stepper_motor/journey/if_condition_test.rb
283
316
  - test/stepper_motor/journey/step_definition_test.rb
284
317
  - test/stepper_motor/journey/uniqueness_test.rb
285
318
  - test/stepper_motor/journey_test.rb
@@ -297,7 +330,6 @@ metadata:
297
330
  homepage_uri: https://steppermotor.dev
298
331
  source_code_uri: https://github.com/stepper-motor/stepper_motor
299
332
  changelog_uri: https://github.com/stepper-motor/stepper_motor/blob/main/CHANGELOG.md
300
- post_install_message:
301
333
  rdoc_options: []
302
334
  require_paths:
303
335
  - lib
@@ -312,8 +344,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
312
344
  - !ruby/object:Gem::Version
313
345
  version: '0'
314
346
  requirements: []
315
- rubygems_version: 3.4.10
316
- signing_key:
347
+ rubygems_version: 3.6.6
317
348
  specification_version: 4
318
349
  summary: Effortless step workflows that embed nicely inside Rails
319
350
  test_files: []