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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -8
- data/CHANGELOG.md +10 -0
- data/lib/generators/stepper_motor_migration_005.rb.erb +58 -0
- data/lib/stepper_motor/journey.rb +11 -8
- data/lib/stepper_motor/step.rb +36 -8
- data/lib/stepper_motor/version.rb +1 -1
- data/manual/MANUAL.md +337 -86
- data/rbi/stepper_motor.rbi +18 -4
- data/sig/stepper_motor.rbs +15 -2
- data/stepper_motor.gemspec +2 -0
- data/test/dummy/config/database.mysql2.yml +14 -0
- data/test/dummy/config/database.postgres.yml +14 -0
- data/test/dummy/config/database.sqlite3.yml +32 -0
- data/test/dummy/config/initializers/stepper_motor.rb +1 -1
- data/test/dummy/db/migrate/20250609221201_stepper_motor_migration_005.rb +58 -0
- data/test/dummy/db/schema.rb +7 -6
- data/test/stepper_motor/journey/if_condition_test.rb +286 -0
- data/test/stepper_motor/journey/step_definition_test.rb +1 -0
- metadata +37 -6
@@ -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
|
data/test/dummy/db/schema.rb
CHANGED
@@ -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:
|
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 ["
|
30
|
-
t.index ["
|
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"
|
34
|
-
t.index ["updated_at"], name: "stuck_journeys_index"
|
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.
|
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-
|
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.
|
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: []
|