-
Notifications
You must be signed in to change notification settings - Fork 10
/
machinist.ex
504 lines (372 loc) · 15.6 KB
/
machinist.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
defmodule Machinist do
@moduledoc """
`Machinist` is a small library that allows you to implement finite state machines
in a simple way. It provides a simple DSL to write combinations of
transitions based on events.
A good example is how we would implement the functioning of a door. With `machinist` would be this way:
defmodule Door do
defstruct [state: :locked]
use Machinist
transitions do
from :locked, to: :unlocked, event: "unlock"
from :unlocked, to: :locked, event: "lock"
from :unlocked, to: :opened, event: "open"
from :opened, to: :closed, event: "close"
from :closed, to: :opened, event: "open"
from :closed, to: :locked, event: "lock"
end
end
By defining this rules with `transitions` and `from` macros, `machinist` generates and inject into the module `Door` `transit/2` functions like this one:
def transit(%Door{state: :locked} = struct, event: "unlock") do
{:ok, %Door{struct | state: :unlocked}}
end
_The functions `transit/2` implements the behaviour_ `Machinist.Transition`
So that we can transit between states by relying on the **state** + **event** pattern matching.
Let's see this in practice:
By default our `Door` is `locked`
iex> door_locked = %Door{}
iex> %Door{state: :locked}
So let's change its state to `unlocked` and `opened`
iex> {:ok, door_unlocked} = Door.transit(door_locked, event: "unlock")
iex> {:ok, %Door{state: :unlocked}}
iex> {:ok, door_opened} = Door.transit(door_unlocked, event: "open")
iex> {:ok, %Door{state: :opened}}
If we try to make a transition that not follow the rules, we got an error:
iex> Door.transit(door_opened, event: "lock")
iex> {:error, :not_allowed}
### Group same-state `from` definitions
In the example above we also could group the `from :unlocked` definitions like this:
# ...
transitions do
from :locked, to: :unlocked, event: "unlock"
from :unlocked do
to :locked, event: "lock"
to :opened, event: "open"
end
from :opened, to: :closed, event: "close"
from :closed, to: :opened, event: "open"
from :closed, to: :locked, event: "lock"
end
# ...
This is an option for a better organization and an increase of readability when having
a large number of `from` definitions with a same state.
### Setting different attribute name that holds the state
By default `machinist` expects the struct being updated holds a `state` attribute,
if you hold state in a different attribute, just pass the name as an atom, as follows:
transitions attr: :door_state do
# ...
end
And then `machinist` will set state in that attribute
iex> Door.transit(door, event: "unlock")
iex> {:ok, %Door{door_state: :unlocked}}
### Implementing different versions of a state machine
Let's suppose we want to build a selection process app that handles applications
of candidates and they may possibly going through different versions of the process. For example:
A Selection Process **V1** with the following sequence of stages: [Registration] -> [**Code test**] -> [Enrollment]
And a Selection Process **V2** with these ones: [Registration] -> [**Interview**] -> [Enrollment]
The difference here is in **V1** candidates must take a **Code Test** and V2 an **Interview**.
So, we could have a `%Candidate{}` struct that holds these attributes:
defmodule SelectionProcess.Candidate do
defstruct [:name, :state, test_score: 0]
end
And a `SelectionProcess` module that implements the state machine.
Notice this time we don't want to implement the rules in the module that holds
the state, in this case it makes more sense the `SelectionProcess` keep the rules,
also because we want more than one state machine version handling candidates as mentioned before.
This is our **V1** of the process:
defmodule SelectionProcess.V1 do
use Machinist
alias SelectionProcess.Candidate
@minimum_score 100
transitions Candidate do
from :new, to: :registered, event: "register"
from :registered, to: :started_test, event: "start_test"
from :started_test, to: &check_score/1, event: "send_test"
from :approved, to: :enrolled, event: "enroll"
end
defp check_score(%Candidate{test_score: score}) do
if score >= @minimum_score, do: :approved, else: :reproved
end
end
In this code we pass the `Candidate` module as a parameter to `transitions`
to tell `machinist` that we expect `V1.transit/2` functions with a `%Candidate{}`
struct as first argument and not the `%SelectionProcess.V1{}` which would be by default.
def transit(%Candidate{state: :new} = struct, event: "register") do
{:ok, %Candidate{struct | state: :registered}}
end
Also notice we provided the *function* `&check_score/1` to the option `to:` instead of an *atom*, in order to decide the state based on the candidate `test_score` value.
In the **version 2**, we replaced the `Code Test` stage by the `Interview` which has different state transitions:
defmodule SelectionProcess.V2 do
use Machinist
alias SelectionProcess.Candidate
transitions Candidate do
from :new, to: :registered, event: "register"
from :registered, to: :interview_scheduled, event: "schedule_interview"
from :interview_scheduled, to: :approved, event: "approve_interview"
from :interview_scheduled, to: :repproved, event: "reprove_interview"
from :approved, to: :enrolled, event: "enroll"
end
end
Now let's see how this could be used:
**V1:** A `registered` candidate wants to start its test.
iex> candidate1 = %Candidate{name: "Ada", state: :registered}
iex> SelectionProcess.V1.transit(candidate1, event: "start_test")
iex> %{:ok, %Candidate{state: :test_started}}
**V2:** A `registered` candidate wants to schedule the interview
iex> candidate2 = %Candidate{name: "Jose", state: :registered}
iex> SelectionProcess.V2.transit(candidate1, event: "schedule_interview")
iex> %{:ok, %Candidate{state: :interview_scheduled}}
That's great because we also can implement many state machines for only one
entity and test different scenarios, evaluate and collect data for deciding which one is better.
`machinist` gives us this flexibility since it's just pure Elixir.
### Transiting from any state to another
Sometimes we need to define a `from` _any state_ transition.
Still in the selection process example, a candidate can abandon the process in a given state and we want to be able to transit him/her to `application_expired` from any state. To do so we just define a `from` with an underscore variable in order the current state to be ignored.
defmodule SelectionProcess.V2 do
use Machinist
alias SelectionProcess.Candidate
transitions Candidate do
# ...
from _state, to: :application_expired, event: "application_expired"
end
end
## Introspection
To get the list of states, just call:
iex> SelectionProcess.V2.__states__()
[:new, :registered, :interview_scheduled, :approved, :reproved, :enrolled]
To get the list of events:
iex> SelectionProcess.V2.__events__()
["register", "schedule_interview", "approve_interview", "reprove_interview", "enroll"]
To get the list of all transitions:
iex> SelectionProcess.V2.__transitions__()
[
[from: :new, to: :registered, event: "register"],
[from: :registered, to: :interview_scheduled, event: "schedule_interview"],
[from: :interview_scheduled, to: :approved, event: "approve_interview"],
[from: :interview_scheduled, to: :repproved, event: "reprove_interview"],
[from: :approved, to: :enrolled, event: "enroll"]
]
## How does the DSL works?
The use of `transitions` in combination with each `from` statement will be
transformed in functions that will be injected into the module that is using `machinist`.
This implementation:
defmodule Door do
defstruct state: :locked
use Machinist
transitions do
from :locked, to: :unlocked, event: "unlock"
from :unlocked, to: :locked, event: "lock"
from :unlocked, to: :opened, event: "open"
from :opened, to: :closed, event: "close"
from :closed, to: :opened, event: "open"
from :closed, to: :locked, event: "lock"
end
end
is the same as:
defmodule Door do
defstruct state: :locked
def transit(%__MODULE__{state: :locked} = struct, event: "unlock") do
{:ok, %__MODULE__{struct | state: :unlocked}}
end
def transit(%__MODULE__{state: :unlocked} = struct, event: "lock") do
{:ok, %__MODULE__{struct | state: :locked}}
end
def transit(%__MODULE__{state: :unlocked} = struct, event: "open") do
{:ok, %__MODULE__{struct | state: :opened}}
end
def transit(%__MODULE__{state: :opened} = struct, event: "close") do
{:ok, %__MODULE__{struct | state: :closed}}
end
def transit(%__MODULE__{state: :closed} = struct, event: "open") do
{:ok, %__MODULE__{struct | state: :opened}}
end
def transit(%__MODULE__{state: :closed} = struct, event: "lock") do
{:ok, %__MODULE__{struct | state: :locked}}
end
# a catchall function in case of unmatched clauses
def transit(_, _), do: {:error, :not_allowed}
end
So, as we can see, we can eliminate a lot of boilerplate with `machinist` making
it easier to maintain and less prone to errors.
"""
@doc false
defmacro __using__(_) do
quote do
Module.register_attribute(__MODULE__, :__states__, accumulate: true, persist: false)
Module.register_attribute(__MODULE__, :__events__, accumulate: true, persist: false)
Module.register_attribute(__MODULE__, :__transitions__, accumulate: true, persist: false)
@__attr__ :state
@behaviour Machinist.Transition
import unquote(__MODULE__)
@before_compile unquote(__MODULE__)
end
end
@doc """
Defines a block of transitions.
By default `transitions/1` expects the module using `Machinist` has a struct
defined with a `state` attribute
transitions do
# ...
end
"""
defmacro transitions(do: block) do
quote do
@__struct__ __MODULE__
unquote(block)
end
end
@doc """
Defines a block of transitions for a specific struct or defines a block of
transitions just passing the `attr` option to define the attribute holding the state
## Examples
### A Candidate being handled by two different versions of a SelectionProcess
defmodule Candidate do
defstruct state: :new
end
defmodule SelectionProcess.V1 do
use Machinist
transitions Candidate do
from :new, to: :registered, event: "register"
end
end
defmodule SelectionProcess.V2 do
use Machinist
transitions Candidate do
from :new, to: :enrolled, event: "enroll"
end
end
### Providing the `attr` option to define the attribute holding the state
defmodule Candidate do
defstruct candidate_state: :new
use Machinist
transitions attr: :candidate_state do
from :new, to: :registered, event: "register"
end
end
"""
defmacro transitions(list_or_struct, block)
defmacro transitions([attr: attr], do: block) do
quote do
@__attr__ unquote(attr)
@__struct__ __MODULE__
unquote(block)
end
end
defmacro transitions(struct, do: block) do
quote do
@__struct__ unquote(struct)
unquote(block)
end
end
@doc """
Defines a block of transitions for a specific struct with `attr` option
defining the attribute holding the state
transitions Candidate, attr: :candidate_state do
# ...
end
"""
defmacro transitions(struct, [attr: attr], do: block) do
quote do
@__attr__ unquote(attr)
@__struct__ unquote(struct)
unquote(block)
end
end
@doc """
Defines a state transition with the given `state`, and the list of options `[to: new_state, event: event]`
from 1, to: 2, event: "next"
It's also possible to define a `from` any state transition to another specific one, by just passing an underscore variable in place of a real state value
from _state, to: :expired, event: "enrollment_expired"
"""
defmacro from(state, do: {_, _line, to_statements}) do
deftransitions(state, to_statements)
end
defmacro from(state, to: new_state, event: event) do
deftransition(state, to: new_state, event: event)
end
@doc false
defp deftransitions(_state, []), do: []
@doc false
defp deftransitions(state, [{:to, _line, [new_state, [event: event]]} | transitions]) do
[
deftransition(state, to: new_state, event: event)
| deftransitions(state, transitions)
]
end
@doc false
defp deftransition(state, to: new_state, event: event) do
ast_states = accumulate_attribute_states(state, new_state)
ast_events = accumulate_attribute_events(event)
ast_transitions = accumulate_attribute_transitions(state, new_state, event)
quote do
unquote(ast_states)
unquote(ast_events)
unquote(ast_transitions)
@impl true
def transit(%@__struct__{@__attr__ => unquote(state)} = resource, event: unquote(event)) do
value = __set_new_state__(resource, unquote(new_state))
{:ok, Map.put(resource, @__attr__, value)}
end
end
end
defp accumulate_attribute_transitions(state, new_state, event) do
if not unused_var?(state) do
quote bind_quoted: [state: state, new_state: new_state, event: event] do
@__transitions__ [from: state, to: new_state, event: event]
end
else
quote bind_quoted: [new_state: new_state, event: event] do
@__transitions__ [from: :any, to: new_state, event: event]
end
end
end
defp accumulate_attribute_states(state, new_state) do
if not unused_var?(state) do
quote bind_quoted: [state: state, new_state: new_state] do
if state not in @__states__ do
@__states__ state
end
if new_state not in @__states__ do
@__states__ new_state
end
end
end
end
defp accumulate_attribute_events(event) do
quote bind_quoted: [event: event] do
if event not in @__events__ do
@__events__ event
end
end
end
defp unused_var?(state), do: match?({_, _, nil}, state)
@doc false
defmacro __before_compile__(_) do
quote do
@impl true
def transit(_resource, _opts) do
{:error, :not_allowed}
end
def __states__, do: Enum.reverse(@__states__)
def __events__, do: Enum.reverse(@__events__)
def __transitions__ do
Enum.reduce(@__transitions__, [], fn
transition, acc ->
if Keyword.get(transition, :from) == :any do
result = for state <- __states__(), do: Keyword.put(transition, :from, state)
result ++ acc
else
[transition | acc]
end
end)
end
defp __set_new_state__(resource, new_state) do
if is_function(new_state) do
new_state.(resource)
else
new_state
end
end
end
end
end