hybrid-state-model 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +22 -0
- data/README.md +308 -0
- data/Rakefile +9 -0
- data/examples/order_example.rb +112 -0
- data/hybrid-state-model.gemspec +42 -0
- data/lib/hybrid_state_model/core.rb +52 -0
- data/lib/hybrid_state_model/metrics.rb +139 -0
- data/lib/hybrid_state_model/query_methods.rb +36 -0
- data/lib/hybrid_state_model/state_machine.rb +143 -0
- data/lib/hybrid_state_model/transition_methods.rb +85 -0
- data/lib/hybrid_state_model/version.rb +6 -0
- data/lib/hybrid_state_model.rb +32 -0
- metadata +132 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ccb639c4ad5854bb0d7d81dd251af7d7bff899112e2a19588b004b2a5c62d4b7
|
|
4
|
+
data.tar.gz: 00a6d76e7b17bca056d3cc61cc3a77d0cd37aadb1246bff82c6c1c1c4becf3a6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 869f995dd5b782d748a2c64ee1f6c6419fa73a906897ea9579d1d8872ba3ba3ce415a8f5ee1b1a19ebaf6e6be56d7c04850a8bd9afda82a3e6c778e8d1e9694f
|
|
7
|
+
data.tar.gz: 29f58c5169ee5c1444931632799e118647387016bab884b91bfffd592de5ce6f2d02b83d83db5c54c7f054dd5f9de4aa82f1e951e91e3eac8e9b4908824a9611
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2024-01-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial release of hybrid-state-model
|
|
12
|
+
- Two-layer state system (primary state + micro state)
|
|
13
|
+
- DSL for defining states and mappings
|
|
14
|
+
- Transition methods: `promote!`, `advance!`, `reset_micro!`, `transition!`
|
|
15
|
+
- Query scopes: `in_primary`, `in_micro`, `with_primary_and_micro`, `with_micro`, `without_micro`
|
|
16
|
+
- Automatic state validation
|
|
17
|
+
- Callbacks: `before_primary_transition`, `after_primary_transition`, `before_micro_transition`, `after_micro_transition`
|
|
18
|
+
- Optional metrics tracking for time spent in states
|
|
19
|
+
- Support for auto-resetting micro state when primary state changes
|
|
20
|
+
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# Hybrid State Model
|
|
2
|
+
|
|
3
|
+
A revolutionary two-layer hierarchical state system for Ruby models. Perfect for complex workflows that are too complex for flat state machines but don't need full orchestration engines.
|
|
4
|
+
|
|
5
|
+
## 🌟 The Big Idea
|
|
6
|
+
|
|
7
|
+
**hybrid-state-model** introduces a two-layer state system:
|
|
8
|
+
|
|
9
|
+
- **Primary State** — high-level lifecycle (e.g., `pending`, `active`, `shipped`, `delivered`)
|
|
10
|
+
- **Secondary Micro-State** — small step within the primary state (e.g., `verifying_email`, `awaiting_payment`, `in_transit`, `out_for_delivery`)
|
|
11
|
+
|
|
12
|
+
This creates a simple but powerful hierarchical state system that reduces complexity instead of adding it.
|
|
13
|
+
|
|
14
|
+
## ✨ Features
|
|
15
|
+
|
|
16
|
+
- 🎯 **Two-layer state system** — Primary states with nested micro-states
|
|
17
|
+
- 🔒 **Automatic constraints** — Micro-states are validated against their primary state
|
|
18
|
+
- 🚀 **Flexible transitions** — `promote!`, `advance!`, `reset_micro!`, and `transition!`
|
|
19
|
+
- 🔍 **Querying capabilities** — `in_primary`, `in_micro`, `with_primary_and_micro` scopes
|
|
20
|
+
- 📊 **Metrics tracking** — Track time spent in each state (optional)
|
|
21
|
+
- 🎛️ **Callbacks** — `before_primary_transition`, `after_primary_transition`, etc.
|
|
22
|
+
- ✅ **ActiveRecord integration** — Works seamlessly with Rails models
|
|
23
|
+
|
|
24
|
+
## 📦 Installation
|
|
25
|
+
|
|
26
|
+
Add this line to your application's Gemfile:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
gem 'hybrid-state-model'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
And then execute:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
$ bundle install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or install it yourself as:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
$ gem install hybrid-state-model
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 🚀 Quick Start
|
|
45
|
+
|
|
46
|
+
### 1. Create your migration
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class CreateOrders < ActiveRecord::Migration[7.0]
|
|
50
|
+
def change
|
|
51
|
+
create_table :orders do |t|
|
|
52
|
+
t.string :status # Primary state
|
|
53
|
+
t.string :sub_status # Micro state
|
|
54
|
+
t.text :state_metrics # Optional: for metrics tracking
|
|
55
|
+
|
|
56
|
+
t.timestamps
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2. Define your model
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class Order < ActiveRecord::Base
|
|
66
|
+
include HybridStateModel
|
|
67
|
+
|
|
68
|
+
hybrid_state do
|
|
69
|
+
# Define primary state field and possible values
|
|
70
|
+
primary :status, %i[pending processing shipped delivered returned]
|
|
71
|
+
|
|
72
|
+
# Define micro state field and possible values
|
|
73
|
+
micro :sub_status, %i[
|
|
74
|
+
awaiting_payment
|
|
75
|
+
fraud_check_passed
|
|
76
|
+
fraud_check_failed
|
|
77
|
+
ready_to_pack
|
|
78
|
+
packing
|
|
79
|
+
assigning_carrier
|
|
80
|
+
waiting_for_pickup
|
|
81
|
+
in_transit
|
|
82
|
+
out_for_delivery
|
|
83
|
+
inspection
|
|
84
|
+
return_processing
|
|
85
|
+
return_complete
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Map which micro-states are allowed for each primary state
|
|
89
|
+
map status: :pending, sub_status: %i[awaiting_payment]
|
|
90
|
+
map status: :processing, sub_status: %i[fraud_check_passed fraud_check_failed ready_to_pack packing assigning_carrier]
|
|
91
|
+
map status: :shipped, sub_status: %i[waiting_for_pickup in_transit out_for_delivery]
|
|
92
|
+
map status: :returned, sub_status: %i[inspection return_processing return_complete]
|
|
93
|
+
|
|
94
|
+
# Optional: Reset micro state when primary state changes
|
|
95
|
+
when_primary_changes reset_micro: true
|
|
96
|
+
|
|
97
|
+
# Optional: Callbacks
|
|
98
|
+
before_primary_transition :shipped do
|
|
99
|
+
raise "Cannot ship without payment" unless paid?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
after_primary_transition :delivered do
|
|
103
|
+
send_delivery_confirmation_email
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 3. Use it!
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# Create an order
|
|
113
|
+
order = Order.create!(status: :pending, sub_status: :awaiting_payment)
|
|
114
|
+
|
|
115
|
+
# Promote to primary state (moves to next major state)
|
|
116
|
+
order.promote!(:processing)
|
|
117
|
+
# => status: :processing, sub_status: nil (reset because of when_primary_changes)
|
|
118
|
+
|
|
119
|
+
# Advance micro state (moves within current primary state)
|
|
120
|
+
order.advance!(:ready_to_pack)
|
|
121
|
+
# => status: :processing, sub_status: :ready_to_pack
|
|
122
|
+
|
|
123
|
+
# Transition both at once
|
|
124
|
+
order.transition!(primary: :shipped, micro: :waiting_for_pickup)
|
|
125
|
+
# => status: :shipped, sub_status: :waiting_for_pickup
|
|
126
|
+
|
|
127
|
+
# Advance through micro states
|
|
128
|
+
order.advance!(:in_transit)
|
|
129
|
+
order.advance!(:out_for_delivery)
|
|
130
|
+
|
|
131
|
+
# Promote to final state
|
|
132
|
+
order.promote!(:delivered)
|
|
133
|
+
|
|
134
|
+
# Querying
|
|
135
|
+
Order.in_primary(:shipped)
|
|
136
|
+
Order.in_micro(:in_transit)
|
|
137
|
+
Order.with_primary_and_micro(primary: :shipped, micro: :out_for_delivery)
|
|
138
|
+
Order.with_micro # Orders that have a micro state
|
|
139
|
+
Order.without_micro # Orders without a micro state
|
|
140
|
+
|
|
141
|
+
# Validation
|
|
142
|
+
order.status = :delivered
|
|
143
|
+
order.sub_status = :assigning_carrier # ❌ Invalid! Will fail validation
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## 📚 API Reference
|
|
147
|
+
|
|
148
|
+
### DSL Methods
|
|
149
|
+
|
|
150
|
+
#### `primary(field_name, states)`
|
|
151
|
+
Defines the primary state field and its possible values.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
primary :status, %i[pending active inactive]
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### `micro(field_name, states)`
|
|
158
|
+
Defines the micro state field and its possible values.
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
micro :sub_status, %i[verifying_email awaiting_approval]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### `map(primary_state:, micro_states:)`
|
|
165
|
+
Maps which micro-states are allowed for a specific primary state.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
map status: :active, sub_status: %i[verifying_email awaiting_approval]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### `when_primary_changes(reset_micro: true)`
|
|
172
|
+
Automatically resets the micro state when the primary state changes.
|
|
173
|
+
|
|
174
|
+
#### `before_primary_transition(states, &block)`
|
|
175
|
+
Runs a callback before transitioning to the specified primary state(s).
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
before_primary_transition :shipped do
|
|
179
|
+
validate_shipping_address
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### `after_primary_transition(states, &block)`
|
|
184
|
+
Runs a callback after transitioning to the specified primary state(s).
|
|
185
|
+
|
|
186
|
+
#### `before_micro_transition(states, &block)`
|
|
187
|
+
Runs a callback before transitioning to the specified micro state(s).
|
|
188
|
+
|
|
189
|
+
#### `after_micro_transition(states, &block)`
|
|
190
|
+
Runs a callback after transitioning to the specified micro state(s).
|
|
191
|
+
|
|
192
|
+
### Instance Methods
|
|
193
|
+
|
|
194
|
+
#### `promote!(new_primary_state, options = {})`
|
|
195
|
+
Transitions to a new primary state. Automatically resets micro state if configured.
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
order.promote!(:shipped)
|
|
199
|
+
order.promote!(:delivered, skip_save: true) # Don't save immediately
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### `advance!(new_micro_state, options = {})`
|
|
203
|
+
Transitions to a new micro state within the current primary state.
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
order.advance!(:in_transit)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### `reset_micro!(options = {})`
|
|
210
|
+
Resets the micro state to `nil`.
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
order.reset_micro!
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### `transition!(primary:, micro:, options = {})`
|
|
217
|
+
Transitions both primary and micro states at once.
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
order.transition!(primary: :shipped, micro: :waiting_for_pickup)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### `can_transition_to_primary?(state)`
|
|
224
|
+
Checks if the record can transition to the specified primary state.
|
|
225
|
+
|
|
226
|
+
#### `can_transition_to_micro?(state)`
|
|
227
|
+
Checks if the record can transition to the specified micro state.
|
|
228
|
+
|
|
229
|
+
### Query Scopes
|
|
230
|
+
|
|
231
|
+
#### `in_primary(*states)`
|
|
232
|
+
Finds records with the specified primary state(s).
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Order.in_primary(:shipped, :delivered)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### `in_micro(*states)`
|
|
239
|
+
Finds records with the specified micro state(s).
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
Order.in_micro(:in_transit, :out_for_delivery)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
#### `with_primary_and_micro(primary:, micro:)`
|
|
246
|
+
Finds records with both the specified primary and micro states.
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
Order.with_primary_and_micro(primary: :shipped, micro: :in_transit)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### `with_micro`
|
|
253
|
+
Finds records that have a micro state set.
|
|
254
|
+
|
|
255
|
+
#### `without_micro`
|
|
256
|
+
Finds records that don't have a micro state set.
|
|
257
|
+
|
|
258
|
+
## 📊 Metrics Tracking
|
|
259
|
+
|
|
260
|
+
If you add a `state_metrics` text/json column to your table, the gem will automatically track time spent in each state:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
# Migration
|
|
264
|
+
add_column :orders, :state_metrics, :text
|
|
265
|
+
|
|
266
|
+
# Usage
|
|
267
|
+
order.state_metrics
|
|
268
|
+
# => {
|
|
269
|
+
# "pending" => {"entered_at" => "...", "duration" => 120.5},
|
|
270
|
+
# "processing" => {"entered_at" => "...", "duration" => 300.0},
|
|
271
|
+
# "processing:ready_to_pack" => {"entered_at" => "...", "duration" => 60.0}
|
|
272
|
+
# }
|
|
273
|
+
|
|
274
|
+
order.time_in_primary_state(:processing)
|
|
275
|
+
# => 300.0 (seconds)
|
|
276
|
+
|
|
277
|
+
order.current_state_duration
|
|
278
|
+
# => 45.2 (seconds in current state)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## 🎯 Use Cases
|
|
282
|
+
|
|
283
|
+
### Logistics & Shipping
|
|
284
|
+
```ruby
|
|
285
|
+
primary :status, %i[pending processing shipped delivered]
|
|
286
|
+
micro :sub_status, %i[ready_to_pack packing waiting_for_pickup in_transit out_for_delivery]
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Payment & Billing
|
|
290
|
+
```ruby
|
|
291
|
+
primary :status, %i[active suspended canceled]
|
|
292
|
+
micro :sub_status, %i[verifying_card awaiting_payment retrying_charge]
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### User Onboarding
|
|
296
|
+
```ruby
|
|
297
|
+
primary :status, %i[active pending]
|
|
298
|
+
micro :sub_status, %i[verifying_email uploading_documents awaiting_approval]
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## 🤝 Contributing
|
|
302
|
+
|
|
303
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/afshmini/hybrid-state-model.
|
|
304
|
+
|
|
305
|
+
## 📝 License
|
|
306
|
+
|
|
307
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
308
|
+
|
data/Rakefile
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example: Order model with hybrid state
|
|
4
|
+
#
|
|
5
|
+
# This example demonstrates how to use hybrid-state-model
|
|
6
|
+
# for an e-commerce order workflow
|
|
7
|
+
|
|
8
|
+
require "active_record"
|
|
9
|
+
require_relative "../lib/hybrid_state_model"
|
|
10
|
+
|
|
11
|
+
# Setup database (in a real app, this would be a migration)
|
|
12
|
+
ActiveRecord::Base.establish_connection(
|
|
13
|
+
adapter: "sqlite3",
|
|
14
|
+
database: ":memory:"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
ActiveRecord::Schema.define do
|
|
18
|
+
create_table :orders do |t|
|
|
19
|
+
t.string :status
|
|
20
|
+
t.string :sub_status
|
|
21
|
+
t.text :state_metrics
|
|
22
|
+
t.boolean :paid, default: false
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Order < ActiveRecord::Base
|
|
28
|
+
include HybridStateModel
|
|
29
|
+
|
|
30
|
+
hybrid_state do
|
|
31
|
+
# Define primary state (high-level lifecycle)
|
|
32
|
+
primary :status, %i[pending processing shipped delivered returned]
|
|
33
|
+
|
|
34
|
+
# Define micro state (steps within primary state)
|
|
35
|
+
micro :sub_status, %i[
|
|
36
|
+
awaiting_payment
|
|
37
|
+
fraud_check_passed
|
|
38
|
+
fraud_check_failed
|
|
39
|
+
ready_to_pack
|
|
40
|
+
packing
|
|
41
|
+
assigning_carrier
|
|
42
|
+
waiting_for_pickup
|
|
43
|
+
in_transit
|
|
44
|
+
out_for_delivery
|
|
45
|
+
inspection
|
|
46
|
+
return_processing
|
|
47
|
+
return_complete
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Map which micro-states are allowed for each primary state
|
|
51
|
+
map status: :pending, sub_status: %i[awaiting_payment]
|
|
52
|
+
map status: :processing, sub_status: %i[fraud_check_passed fraud_check_failed ready_to_pack packing assigning_carrier]
|
|
53
|
+
map status: :shipped, sub_status: %i[waiting_for_pickup in_transit out_for_delivery]
|
|
54
|
+
map status: :returned, sub_status: %i[inspection return_processing return_complete]
|
|
55
|
+
|
|
56
|
+
# Automatically reset micro state when primary state changes
|
|
57
|
+
when_primary_changes reset_micro: true
|
|
58
|
+
|
|
59
|
+
# Callbacks
|
|
60
|
+
before_primary_transition :shipped do
|
|
61
|
+
raise "Cannot ship unpaid order" unless paid?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
after_primary_transition :delivered do
|
|
65
|
+
puts "Order #{id} has been delivered!"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Example usage
|
|
71
|
+
if __FILE__ == $PROGRAM_NAME
|
|
72
|
+
puts "=== Hybrid State Model Example ===\n\n"
|
|
73
|
+
|
|
74
|
+
# Create an order
|
|
75
|
+
order = Order.create!(status: :pending, sub_status: :awaiting_payment, paid: false)
|
|
76
|
+
puts "1. Created order: #{order.status} / #{order.sub_status}"
|
|
77
|
+
|
|
78
|
+
# Mark as paid and move to processing
|
|
79
|
+
order.update!(paid: true)
|
|
80
|
+
order.promote!(:processing)
|
|
81
|
+
puts "2. Promoted to: #{order.status} / #{order.sub_status}"
|
|
82
|
+
|
|
83
|
+
# Advance through micro states
|
|
84
|
+
order.advance!(:ready_to_pack)
|
|
85
|
+
puts "3. Advanced to: #{order.status} / #{order.sub_status}"
|
|
86
|
+
|
|
87
|
+
order.advance!(:packing)
|
|
88
|
+
puts "4. Advanced to: #{order.status} / #{order.sub_status}"
|
|
89
|
+
|
|
90
|
+
# Transition both at once
|
|
91
|
+
order.transition!(primary: :shipped, micro: :waiting_for_pickup)
|
|
92
|
+
puts "5. Transitioned to: #{order.status} / #{order.sub_status}"
|
|
93
|
+
|
|
94
|
+
# Continue through shipping micro states
|
|
95
|
+
order.advance!(:in_transit)
|
|
96
|
+
puts "6. Advanced to: #{order.status} / #{order.sub_status}"
|
|
97
|
+
|
|
98
|
+
order.advance!(:out_for_delivery)
|
|
99
|
+
puts "7. Advanced to: #{order.status} / #{order.sub_status}"
|
|
100
|
+
|
|
101
|
+
# Final state
|
|
102
|
+
order.promote!(:delivered)
|
|
103
|
+
puts "8. Final state: #{order.status} / #{order.sub_status}"
|
|
104
|
+
|
|
105
|
+
# Query examples
|
|
106
|
+
puts "\n=== Query Examples ==="
|
|
107
|
+
puts "Orders in 'shipped' state: #{Order.in_primary(:shipped).count}"
|
|
108
|
+
puts "Orders with micro state 'in_transit': #{Order.in_micro(:in_transit).count}"
|
|
109
|
+
|
|
110
|
+
puts "\n=== Example Complete ==="
|
|
111
|
+
end
|
|
112
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/hybrid_state_model/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "hybrid-state-model"
|
|
7
|
+
spec.version = HybridStateModel::VERSION
|
|
8
|
+
spec.authors = ["Your Name"]
|
|
9
|
+
spec.email = ["your.email@example.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "A two-layer hierarchical state system for Ruby models"
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
hybrid-state-model introduces a revolutionary two-layer state system:
|
|
14
|
+
Primary State (high-level lifecycle) and Secondary Micro-State (small steps within primary state).
|
|
15
|
+
Perfect for complex workflows that are too complex for flat state machines but don't need full orchestration.
|
|
16
|
+
DESC
|
|
17
|
+
spec.homepage = "https://github.com/afshmini/hybrid-state-model"
|
|
18
|
+
spec.license = "MIT"
|
|
19
|
+
spec.required_ruby_version = ">= 2.7.0"
|
|
20
|
+
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
24
|
+
|
|
25
|
+
# Specify which files should be added to the gem when it is released.
|
|
26
|
+
spec.files = Dir.chdir(__dir__) do
|
|
27
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
28
|
+
(File.expand_path(f) == __FILE__) ||
|
|
29
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
spec.bindir = "exe"
|
|
33
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
34
|
+
spec.require_paths = ["lib"]
|
|
35
|
+
|
|
36
|
+
spec.add_dependency "activerecord", ">= 5.2.0"
|
|
37
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
38
|
+
spec.add_development_dependency "sqlite3", "~> 1.6"
|
|
39
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
40
|
+
spec.add_development_dependency "rubocop", "~> 1.21"
|
|
41
|
+
end
|
|
42
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HybridStateModel
|
|
4
|
+
module Core
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def hybrid_state_model?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def primary_state
|
|
16
|
+
state_machine.primary_field
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def micro_state
|
|
20
|
+
state_machine.micro_field
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def state_machine
|
|
24
|
+
self.class.state_machine
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def primary_state_value
|
|
28
|
+
send(primary_state)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def micro_state_value
|
|
32
|
+
send(micro_state)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid_primary_state?(state)
|
|
36
|
+
state_machine.valid_primary_state?(state)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def valid_micro_state?(state)
|
|
40
|
+
state_machine.valid_micro_state?(state, primary_state_value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def can_transition_to_primary?(new_state)
|
|
44
|
+
state_machine.can_transition_to_primary?(self, new_state)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def can_transition_to_micro?(new_micro_state)
|
|
48
|
+
state_machine.can_transition_to_micro?(self, new_micro_state)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module HybridStateModel
|
|
6
|
+
module Metrics
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
def has_state_metrics?
|
|
13
|
+
column_names.include?("state_metrics")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def track_state_changes
|
|
18
|
+
return unless persisted?
|
|
19
|
+
|
|
20
|
+
@state_changed = primary_state_changed? || micro_state_changed?
|
|
21
|
+
@old_primary = primary_state_was if primary_state_changed?
|
|
22
|
+
@old_micro = micro_state_was if micro_state_changed?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_state_metrics
|
|
26
|
+
return unless @state_changed
|
|
27
|
+
|
|
28
|
+
metrics = state_metrics_hash
|
|
29
|
+
current_time = Time.now
|
|
30
|
+
|
|
31
|
+
# Record exit time for old state
|
|
32
|
+
if @old_primary
|
|
33
|
+
metrics[@old_primary.to_s] ||= {}
|
|
34
|
+
metrics[@old_primary.to_s]["exited_at"] ||= current_time.iso8601
|
|
35
|
+
metrics[@old_primary.to_s]["duration"] ||= 0
|
|
36
|
+
if metrics[@old_primary.to_s]["entered_at"]
|
|
37
|
+
duration = current_time - Time.parse(metrics[@old_primary.to_s]["entered_at"])
|
|
38
|
+
metrics[@old_primary.to_s]["duration"] += duration
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Record entry time for new state
|
|
43
|
+
new_primary = primary_state_value
|
|
44
|
+
if new_primary
|
|
45
|
+
metrics[new_primary.to_s] ||= {}
|
|
46
|
+
metrics[new_primary.to_s]["entered_at"] = current_time.iso8601
|
|
47
|
+
metrics[new_primary.to_s]["duration"] ||= 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Record micro state metrics
|
|
51
|
+
if @old_micro && @old_primary
|
|
52
|
+
micro_key = "#{@old_primary}:#{@old_micro}"
|
|
53
|
+
metrics[micro_key] ||= {}
|
|
54
|
+
metrics[micro_key]["exited_at"] ||= current_time.iso8601
|
|
55
|
+
if metrics[micro_key]["entered_at"]
|
|
56
|
+
duration = current_time - Time.parse(metrics[micro_key]["entered_at"])
|
|
57
|
+
metrics[micro_key]["duration"] ||= 0
|
|
58
|
+
metrics[micro_key]["duration"] += duration
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if micro_state_value && new_primary
|
|
63
|
+
micro_key = "#{new_primary}:#{micro_state_value}"
|
|
64
|
+
metrics[micro_key] ||= {}
|
|
65
|
+
metrics[micro_key]["entered_at"] = current_time.iso8601
|
|
66
|
+
metrics[micro_key]["duration"] ||= 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Store metrics if column exists
|
|
70
|
+
if self.class.has_state_metrics?
|
|
71
|
+
self.state_metrics = metrics.to_json
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@state_changed = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def state_metrics
|
|
78
|
+
return {} unless self.class.has_state_metrics?
|
|
79
|
+
|
|
80
|
+
json = read_attribute(:state_metrics)
|
|
81
|
+
return {} if json.blank?
|
|
82
|
+
|
|
83
|
+
JSON.parse(json)
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def state_metrics_hash
|
|
89
|
+
state_metrics
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def time_in_primary_state(state = nil)
|
|
93
|
+
state ||= primary_state_value
|
|
94
|
+
metrics = state_metrics_hash[state.to_s] || {}
|
|
95
|
+
metrics["duration"] || 0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def time_in_micro_state(primary_state, micro_state)
|
|
99
|
+
key = "#{primary_state}:#{micro_state}"
|
|
100
|
+
metrics = state_metrics_hash[key] || {}
|
|
101
|
+
metrics["duration"] || 0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def current_state_duration
|
|
105
|
+
return 0 unless persisted?
|
|
106
|
+
|
|
107
|
+
current_primary = primary_state_value
|
|
108
|
+
return 0 unless current_primary
|
|
109
|
+
|
|
110
|
+
metrics = state_metrics_hash[current_primary.to_s] || {}
|
|
111
|
+
entered_at = metrics["entered_at"]
|
|
112
|
+
|
|
113
|
+
return 0 unless entered_at
|
|
114
|
+
|
|
115
|
+
base_duration = metrics["duration"] || 0
|
|
116
|
+
time_since_entry = Time.now - Time.parse(entered_at)
|
|
117
|
+
base_duration + time_since_entry
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def primary_state_changed?
|
|
123
|
+
send("#{primary_state}_changed?")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def micro_state_changed?
|
|
127
|
+
send("#{micro_state}_changed?")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def primary_state_was
|
|
131
|
+
send("#{primary_state}_was")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def micro_state_was
|
|
135
|
+
send("#{micro_state}_was")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HybridStateModel
|
|
4
|
+
module QueryMethods
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
def in_primary(*states)
|
|
11
|
+
field = state_machine.primary_field
|
|
12
|
+
where(field => states.map(&:to_sym))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def in_micro(*states)
|
|
16
|
+
field = state_machine.micro_field
|
|
17
|
+
where(field => states.map(&:to_sym))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_primary_and_micro(primary:, micro:)
|
|
21
|
+
in_primary(primary).in_micro(micro)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def without_micro
|
|
25
|
+
field = state_machine.micro_field
|
|
26
|
+
where(field => nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_micro
|
|
30
|
+
field = state_machine.micro_field
|
|
31
|
+
where.not(field => nil)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HybridStateModel
|
|
4
|
+
class StateMachine
|
|
5
|
+
attr_reader :model_class, :primary_field, :micro_field, :primary_states, :micro_states,
|
|
6
|
+
:state_mappings, :callbacks, :auto_reset_micro
|
|
7
|
+
|
|
8
|
+
def initialize(model_class)
|
|
9
|
+
@model_class = model_class
|
|
10
|
+
@primary_states = []
|
|
11
|
+
@micro_states = []
|
|
12
|
+
@state_mappings = {}
|
|
13
|
+
@callbacks = {
|
|
14
|
+
before_primary_transition: {},
|
|
15
|
+
after_primary_transition: {},
|
|
16
|
+
before_micro_transition: {},
|
|
17
|
+
after_micro_transition: {}
|
|
18
|
+
}
|
|
19
|
+
@auto_reset_micro = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def primary(field_name, states)
|
|
23
|
+
@primary_field = field_name.to_sym
|
|
24
|
+
@primary_states = states.map(&:to_sym)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def micro(field_name, states)
|
|
28
|
+
@micro_field = field_name.to_sym
|
|
29
|
+
@micro_states = states.map(&:to_sym)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def map(primary_state:, micro_states:)
|
|
33
|
+
primary_key = primary_state.to_sym
|
|
34
|
+
micro_list = Array(micro_states).map(&:to_sym)
|
|
35
|
+
@state_mappings[primary_key] = micro_list
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def before_primary_transition(states, &block)
|
|
39
|
+
Array(states).each do |state|
|
|
40
|
+
@callbacks[:before_primary_transition][state.to_sym] ||= []
|
|
41
|
+
@callbacks[:before_primary_transition][state.to_sym] << block
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def after_primary_transition(states, &block)
|
|
46
|
+
Array(states).each do |state|
|
|
47
|
+
@callbacks[:after_primary_transition][state.to_sym] ||= []
|
|
48
|
+
@callbacks[:after_primary_transition][state.to_sym] << block
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def before_micro_transition(states, &block)
|
|
53
|
+
Array(states).each do |state|
|
|
54
|
+
@callbacks[:before_micro_transition][state.to_sym] ||= []
|
|
55
|
+
@callbacks[:before_micro_transition][state.to_sym] << block
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def after_micro_transition(states, &block)
|
|
60
|
+
Array(states).each do |state|
|
|
61
|
+
@callbacks[:after_micro_transition][state.to_sym] ||= []
|
|
62
|
+
@callbacks[:after_micro_transition][state.to_sym] << block
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def when_primary_changes(reset_micro: false)
|
|
67
|
+
@auto_reset_micro = reset_micro
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def setup!
|
|
71
|
+
raise Error, "Primary state field must be defined" unless @primary_field
|
|
72
|
+
raise Error, "Micro state field must be defined" unless @micro_field
|
|
73
|
+
raise Error, "Primary states must be defined" if @primary_states.empty?
|
|
74
|
+
|
|
75
|
+
@model_class.include(Core)
|
|
76
|
+
@model_class.include(TransitionMethods)
|
|
77
|
+
@model_class.include(QueryMethods)
|
|
78
|
+
@model_class.include(Metrics)
|
|
79
|
+
|
|
80
|
+
setup_validations
|
|
81
|
+
setup_callbacks
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def valid_primary_state?(state)
|
|
85
|
+
@primary_states.include?(state.to_sym)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def valid_micro_state?(micro_state, primary_state)
|
|
89
|
+
return true if micro_state.nil?
|
|
90
|
+
|
|
91
|
+
primary_key = primary_state.to_sym
|
|
92
|
+
allowed_micros = @state_mappings[primary_key]
|
|
93
|
+
|
|
94
|
+
return true if allowed_micros.nil? || allowed_micros.empty?
|
|
95
|
+
|
|
96
|
+
allowed_micros.include?(micro_state.to_sym)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def can_transition_to_primary?(record, new_state)
|
|
100
|
+
return false unless valid_primary_state?(new_state)
|
|
101
|
+
|
|
102
|
+
# Check callbacks
|
|
103
|
+
callbacks = @callbacks[:before_primary_transition][new_state.to_sym] || []
|
|
104
|
+
callbacks.all? { |cb| record.instance_exec(&cb) != false }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def can_transition_to_micro?(record, new_micro_state)
|
|
108
|
+
return false if new_micro_state.nil?
|
|
109
|
+
|
|
110
|
+
current_primary = record.send(@primary_field)
|
|
111
|
+
return false unless valid_micro_state?(new_micro_state, current_primary)
|
|
112
|
+
|
|
113
|
+
# Check callbacks
|
|
114
|
+
callbacks = @callbacks[:before_micro_transition][new_micro_state.to_sym] || []
|
|
115
|
+
callbacks.all? { |cb| record.instance_exec(&cb) != false }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def setup_validations
|
|
121
|
+
@model_class.validate :validate_hybrid_state
|
|
122
|
+
|
|
123
|
+
@model_class.define_method(:validate_hybrid_state) do
|
|
124
|
+
current_primary = send(state_machine.primary_field)
|
|
125
|
+
current_micro = send(state_machine.micro_field)
|
|
126
|
+
|
|
127
|
+
unless state_machine.valid_primary_state?(current_primary)
|
|
128
|
+
errors.add(state_machine.primary_field, "is not a valid primary state")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless state_machine.valid_micro_state?(current_micro, current_primary)
|
|
132
|
+
errors.add(state_machine.micro_field, "is not valid for primary state #{current_primary}")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def setup_callbacks
|
|
138
|
+
@model_class.before_save :track_state_changes
|
|
139
|
+
@model_class.after_save :record_state_metrics
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HybridStateModel
|
|
4
|
+
module TransitionMethods
|
|
5
|
+
def promote!(new_primary_state, options = {})
|
|
6
|
+
new_state = new_primary_state.to_sym
|
|
7
|
+
|
|
8
|
+
unless can_transition_to_primary?(new_state)
|
|
9
|
+
raise InvalidTransitionError, "Cannot transition to primary state: #{new_state}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
old_primary = primary_state_value
|
|
13
|
+
old_micro = micro_state_value
|
|
14
|
+
|
|
15
|
+
# Run before callbacks
|
|
16
|
+
run_callbacks(:before_primary_transition, new_state)
|
|
17
|
+
|
|
18
|
+
# Update primary state
|
|
19
|
+
send("#{primary_state}=", new_state)
|
|
20
|
+
|
|
21
|
+
# Reset micro state if configured or if new state doesn't allow current micro
|
|
22
|
+
if state_machine.auto_reset_micro || !valid_micro_state?(old_micro, new_state)
|
|
23
|
+
send("#{micro_state}=", nil) unless options[:keep_micro]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Save if not already in a transaction
|
|
27
|
+
save! unless options[:skip_save]
|
|
28
|
+
|
|
29
|
+
# Run after callbacks
|
|
30
|
+
run_callbacks(:after_primary_transition, new_state)
|
|
31
|
+
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def advance!(new_micro_state, options = {})
|
|
36
|
+
new_state = new_micro_state.to_sym
|
|
37
|
+
|
|
38
|
+
unless can_transition_to_micro?(new_state)
|
|
39
|
+
raise InvalidTransitionError, "Cannot transition to micro state: #{new_state}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Run before callbacks
|
|
43
|
+
run_callbacks(:before_micro_transition, new_state)
|
|
44
|
+
|
|
45
|
+
# Update micro state
|
|
46
|
+
send("#{micro_state}=", new_state)
|
|
47
|
+
|
|
48
|
+
# Save if not already in a transaction
|
|
49
|
+
save! unless options[:skip_save]
|
|
50
|
+
|
|
51
|
+
# Run after callbacks
|
|
52
|
+
run_callbacks(:after_micro_transition, new_state)
|
|
53
|
+
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset_micro!(options = {})
|
|
58
|
+
send("#{micro_state}=", nil)
|
|
59
|
+
save! unless options[:skip_save]
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def transition!(primary: nil, micro: nil, options = {})
|
|
64
|
+
if primary && primary != primary_state_value
|
|
65
|
+
promote!(primary, options.merge(skip_save: true))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if micro && micro != micro_state_value
|
|
69
|
+
advance!(micro, options)
|
|
70
|
+
elsif primary && !micro
|
|
71
|
+
save! unless options[:skip_save]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def run_callbacks(callback_type, state)
|
|
80
|
+
callbacks = state_machine.callbacks[callback_type][state] || []
|
|
81
|
+
callbacks.each { |cb| instance_exec(&cb) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hybrid_state_model/version"
|
|
4
|
+
require_relative "hybrid_state_model/core"
|
|
5
|
+
require_relative "hybrid_state_model/state_machine"
|
|
6
|
+
require_relative "hybrid_state_model/query_methods"
|
|
7
|
+
require_relative "hybrid_state_model/metrics"
|
|
8
|
+
require_relative "hybrid_state_model/transition_methods"
|
|
9
|
+
|
|
10
|
+
module HybridStateModel
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class InvalidTransitionError < Error; end
|
|
13
|
+
class InvalidStateError < Error; end
|
|
14
|
+
class InvalidMicroStateError < Error; end
|
|
15
|
+
|
|
16
|
+
def self.included(base)
|
|
17
|
+
base.extend(ClassMethods)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module ClassMethods
|
|
21
|
+
def hybrid_state(&block)
|
|
22
|
+
@state_machine = StateMachine.new(self)
|
|
23
|
+
@state_machine.instance_eval(&block)
|
|
24
|
+
@state_machine.setup!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def state_machine
|
|
28
|
+
@state_machine
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
metadata
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hybrid-state-model
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Your Name
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-11-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 5.2.0
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 5.2.0
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.12'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.12'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: sqlite3
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.6'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.6'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '13.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '13.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.21'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.21'
|
|
83
|
+
description: |
|
|
84
|
+
hybrid-state-model introduces a revolutionary two-layer state system:
|
|
85
|
+
Primary State (high-level lifecycle) and Secondary Micro-State (small steps within primary state).
|
|
86
|
+
Perfect for complex workflows that are too complex for flat state machines but don't need full orchestration.
|
|
87
|
+
email:
|
|
88
|
+
- your.email@example.com
|
|
89
|
+
executables: []
|
|
90
|
+
extensions: []
|
|
91
|
+
extra_rdoc_files: []
|
|
92
|
+
files:
|
|
93
|
+
- CHANGELOG.md
|
|
94
|
+
- LICENSE.txt
|
|
95
|
+
- README.md
|
|
96
|
+
- Rakefile
|
|
97
|
+
- examples/order_example.rb
|
|
98
|
+
- hybrid-state-model.gemspec
|
|
99
|
+
- lib/hybrid_state_model.rb
|
|
100
|
+
- lib/hybrid_state_model/core.rb
|
|
101
|
+
- lib/hybrid_state_model/metrics.rb
|
|
102
|
+
- lib/hybrid_state_model/query_methods.rb
|
|
103
|
+
- lib/hybrid_state_model/state_machine.rb
|
|
104
|
+
- lib/hybrid_state_model/transition_methods.rb
|
|
105
|
+
- lib/hybrid_state_model/version.rb
|
|
106
|
+
homepage: https://github.com/afshmini/hybrid-state-model
|
|
107
|
+
licenses:
|
|
108
|
+
- MIT
|
|
109
|
+
metadata:
|
|
110
|
+
homepage_uri: https://github.com/afshmini/hybrid-state-model
|
|
111
|
+
source_code_uri: https://github.com/afshmini/hybrid-state-model
|
|
112
|
+
changelog_uri: https://github.com/afshmini/hybrid-state-model/blob/main/CHANGELOG.md
|
|
113
|
+
post_install_message:
|
|
114
|
+
rdoc_options: []
|
|
115
|
+
require_paths:
|
|
116
|
+
- lib
|
|
117
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: 2.7.0
|
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
123
|
+
requirements:
|
|
124
|
+
- - ">="
|
|
125
|
+
- !ruby/object:Gem::Version
|
|
126
|
+
version: '0'
|
|
127
|
+
requirements: []
|
|
128
|
+
rubygems_version: 3.5.20
|
|
129
|
+
signing_key:
|
|
130
|
+
specification_version: 4
|
|
131
|
+
summary: A two-layer hierarchical state system for Ruby models
|
|
132
|
+
test_files: []
|