statecraft 0.9.0 → 0.10.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 +4 -4
- data/README.md +88 -3
- data/lib/generators/statecraft/from_aasm/USAGE +34 -0
- data/lib/generators/statecraft/from_aasm/from_aasm_generator.rb +250 -0
- data/lib/generators/statecraft/from_aasm/templates/application_machine.rb.tt +8 -0
- data/lib/generators/statecraft/from_aasm/templates/create_log_table.rb.tt +40 -0
- data/lib/generators/statecraft/from_aasm/templates/machine_from_aasm.rb.tt +35 -0
- data/lib/statecraft/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e51b28a602d49b1cf5f71183e5c8b2080bd3e9f4e3a857147a65a7f5529373c
|
|
4
|
+
data.tar.gz: 2068032e1f963037d4521fbca8a02e53d5594bb5ce9902b9c809d1448ba7d9bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4588eac402af35b053ce88660b39efb3a26694fdb9a5f3935844dedfff102f8d687f52af1c3b5da422cd7be99052bc55ef83e3f318d918746ef761bfba49a187
|
|
7
|
+
data.tar.gz: a995c7b056c6a2dd0beae23de1a06ced70f6ba3fbccce223375ea974f0fad506defd0bbf96426f10a3d2c95106be02c37c4075d569d1d8d342199bab8f8a6306
|
data/README.md
CHANGED
|
@@ -161,6 +161,36 @@ refused — the guards would be silently skipped. The escape hatch is explicit:
|
|
|
161
161
|
still run) and records `event: nil` in the log, so audited bypasses stay
|
|
162
162
|
visible.
|
|
163
163
|
|
|
164
|
+
## Which rules belong in a guard
|
|
165
|
+
|
|
166
|
+
Not every rule about a transition is a guard, and the three slots above are
|
|
167
|
+
not an invitation to move form validation into the machine. One question
|
|
168
|
+
draws the line: **would the rule still have to hold if the transition came
|
|
169
|
+
from a job, a console or an ETL run instead of a form?**
|
|
170
|
+
|
|
171
|
+
A rule about the record — this order is on credit, so it is not cancellable
|
|
172
|
+
— always answers yes: it is a fact of the domain, it must hold for every
|
|
173
|
+
caller, and it is checked inside the transaction where nothing can change
|
|
174
|
+
under it. That is a `record_guard:`, and it is the case guards were built
|
|
175
|
+
for.
|
|
176
|
+
|
|
177
|
+
A rule that reads the input earns its place in two situations. The first is
|
|
178
|
+
a rule that compares the input *against the record* — a refund amount that
|
|
179
|
+
must not exceed the order total. One side of that comparison can change
|
|
180
|
+
between a caller's check and the write, so the check belongs inside the
|
|
181
|
+
transaction. The second is a rule protecting the log: metadata is write-once
|
|
182
|
+
and the log is append-only, so a cancellation recorded without its reason
|
|
183
|
+
stays useless forever. The last gate before something becomes permanent is
|
|
184
|
+
worth having in the pipeline, whoever opened it.
|
|
185
|
+
|
|
186
|
+
Everything else is the caller's job. A rule that reads only the input,
|
|
187
|
+
never touches the record, and does not decide whether the log row makes
|
|
188
|
+
sense — a minimum length, a format, a spelling — gains nothing from the
|
|
189
|
+
machine but centralization, and it pays for that with a poorer contract: a
|
|
190
|
+
guard answers with one boolean and a symbol, while a form object answers
|
|
191
|
+
with several errors, attributed to fields, in the user's language. Validate
|
|
192
|
+
the form in the form; hand the machine input it has already accepted.
|
|
193
|
+
|
|
164
194
|
## Callbacks and chains
|
|
165
195
|
|
|
166
196
|
`before_transition`, `after_transition` and `after_commit` accept `from:`,
|
|
@@ -444,9 +474,18 @@ and parse them in the guard.
|
|
|
444
474
|
|
|
445
475
|
Facts of the transition moment (a price snapshot, a rules version) are
|
|
446
476
|
collected by the caller: `order.pay!(metadata: { price: order.total })`.
|
|
447
|
-
There is no metadata schema mechanism
|
|
448
|
-
|
|
449
|
-
|
|
477
|
+
There is no metadata schema mechanism, and the shape evolves by convention:
|
|
478
|
+
carry a `v:` key when you need versioned readers.
|
|
479
|
+
|
|
480
|
+
A guard can require a key, but that is a narrow licence rather than the
|
|
481
|
+
schema mechanism in disguise: the reason to enforce a field here is that
|
|
482
|
+
the row is about to become permanent — metadata is write-once and the log
|
|
483
|
+
is append-only, so a field missing at insert time is missing for good. Note
|
|
484
|
+
what such a guard does *not* buy: metadata is frozen at the pipeline
|
|
485
|
+
entrance, before the transaction opens, so checking it inside a guard is no
|
|
486
|
+
more atomic than checking it in the caller. Shape and format belong to
|
|
487
|
+
whoever assembled the hash — see
|
|
488
|
+
[which rules belong in a guard](#which-rules-belong-in-a-guard).
|
|
450
489
|
|
|
451
490
|
## Initial state is not a transition
|
|
452
491
|
|
|
@@ -575,6 +614,52 @@ statesman never had: see [Stale transitions](#stale-transitions-versioning-again
|
|
|
575
614
|
— the version column is a constant default, so adding it costs a
|
|
576
615
|
metadata-only migration on PostgreSQL 11+.
|
|
577
616
|
|
|
617
|
+
## Migrating from aasm
|
|
618
|
+
|
|
619
|
+
aasm already keeps the current state in a column, so this move is the
|
|
620
|
+
opposite shape of the statesman one: nothing is backfilled, and the
|
|
621
|
+
conversion adds the history aasm never had.
|
|
622
|
+
|
|
623
|
+
```sh
|
|
624
|
+
bin/rails generate statecraft:from_aasm Order
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
The generator reads the live aasm machine off the model (pass a second
|
|
628
|
+
argument to name one machine of a model that declares several) and writes
|
|
629
|
+
the machine skeleton, the log-table migration and the mounting line. More
|
|
630
|
+
survives the trip than from statesman: aasm has real event names, and its
|
|
631
|
+
state column arrives as the mounting's `column:`.
|
|
632
|
+
|
|
633
|
+
Guards come over by nature. An aasm guard judges the record and never sees
|
|
634
|
+
what the caller submitted, which is exactly statecraft's `record_guard:` —
|
|
635
|
+
so a symbol `guard:`/`if:` becomes a `record_guard:` plus a generated
|
|
636
|
+
one-line delegate (`def payable?(record) = record.payable?`, because a
|
|
637
|
+
guard symbol resolves on the machine here, not on the record), and
|
|
638
|
+
`unless: :locked?` becomes a negating delegate. A lambda guard carries its
|
|
639
|
+
own closure and cannot be moved: it arrives as a TODO with its `file:line`.
|
|
640
|
+
|
|
641
|
+
One graph shape is refused rather than guessed. aasm lets one event branch
|
|
642
|
+
from a single state and picks the first transition whose guard passes;
|
|
643
|
+
statecraft makes an event a partial function, so `from` is unique within an
|
|
644
|
+
event. The generator stops, names the branching events, and asks you to
|
|
645
|
+
split them in aasm first — the `pay` / `fail_payment` pattern. Naming the
|
|
646
|
+
halves is a domain decision, and a bad name would outlive the migration.
|
|
647
|
+
|
|
648
|
+
The migration creates the log table and adds an index on the existing state
|
|
649
|
+
column. It deliberately does **not** tighten that column: `NOT NULL`, a
|
|
650
|
+
default and a `CHECK` on a live table mean long locks and an explosion on
|
|
651
|
+
any legacy row outside the state list. The recipe for doing it later, in
|
|
652
|
+
the `NOT VALID` → `VALIDATE CONSTRAINT` style, sits in the migration's own
|
|
653
|
+
header.
|
|
654
|
+
|
|
655
|
+
Then finish by hand (the generator prints the list): drop `include AASM`
|
|
656
|
+
and the `aasm do ... end` block. Event calls keep their names under
|
|
657
|
+
`helpers: true`, but their semantics sharpen — a lost race now raises
|
|
658
|
+
`TransitionConflict` where aasm quietly answered `false` or, without an
|
|
659
|
+
explicit lock, silently overwrote the column. aasm's state predicates
|
|
660
|
+
(`order.paid?`) are not generated: use `in_state?(:paid)` or the `paid`
|
|
661
|
+
scope.
|
|
662
|
+
|
|
578
663
|
## PII and erasure
|
|
579
664
|
|
|
580
665
|
Metadata is the only place personal data can live — `from_state`, `to_state`
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Migrates a model off aasm. aasm already keeps the current state in a
|
|
3
|
+
column, so nothing is backfilled: the conversion adds the append-only
|
|
4
|
+
history aasm never had, indexes the existing column and generates a
|
|
5
|
+
machine skeleton from the live aasm reflection.
|
|
6
|
+
|
|
7
|
+
Copied over: the states, the initial state, the real event names (unlike
|
|
8
|
+
statesman, aasm has them) and the state column, which becomes the
|
|
9
|
+
mounting's `column:`. Symbol guards (guard:/if:/unless:) come over as
|
|
10
|
+
record_guard: with a generated one-line delegate — aasm calls a guard on
|
|
11
|
+
the record, and statecraft resolves a guard symbol on the machine.
|
|
12
|
+
Lambda guards carry their own closure and cannot be moved: each becomes
|
|
13
|
+
a TODO comment with its source location.
|
|
14
|
+
|
|
15
|
+
The second argument names an aasm machine when the model declares
|
|
16
|
+
several (`aasm(:shipment)`); without it the unnamed machine is read.
|
|
17
|
+
An event that branches from one state (aasm picks the first transition
|
|
18
|
+
whose guard passes) is refused: statecraft makes an event a partial
|
|
19
|
+
function, so split it into two named events in aasm first.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
bin/rails generate statecraft:from_aasm Order
|
|
23
|
+
|
|
24
|
+
Creates (when missing) and edits:
|
|
25
|
+
app/state_machines/application_machine.rb
|
|
26
|
+
app/state_machines/order_flow.rb (skeleton: states, events, guards)
|
|
27
|
+
app/models/order.rb (state_machine mounting line)
|
|
28
|
+
db/migrate/XXX_create_order_transitions.rb
|
|
29
|
+
|
|
30
|
+
The migration creates the log table and indexes the state column; the
|
|
31
|
+
column is otherwise left alone, with the NOT NULL / default / CHECK
|
|
32
|
+
recipe in its header. After running it, follow the printed cleanup list:
|
|
33
|
+
drop `include AASM` and the aasm block, and replace aasm's state
|
|
34
|
+
predicates (`order.paid?`) with `in_state?(:paid)` or the scope.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Statecraft
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate statecraft:from_aasm Order [machine_name]` — the door
|
|
9
|
+
# off aasm. Reads the live aasm machine through reflection (states, the
|
|
10
|
+
# initial state, real event names, the state column and the symbol
|
|
11
|
+
# guards), generates the machine skeleton, the log table migration and
|
|
12
|
+
# the mounting line. aasm keeps state in a column already, so nothing is
|
|
13
|
+
# backfilled: the conversion adds the history aasm never had.
|
|
14
|
+
class FromAasmGenerator < Rails::Generators::NamedBase
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
argument :aasm_machine_name, type: :string, required: false,
|
|
20
|
+
desc: "The named aasm machine (default: the unnamed one)"
|
|
21
|
+
|
|
22
|
+
def load_aasm_machine
|
|
23
|
+
@aasm_machine = resolve_aasm_machine
|
|
24
|
+
assert_graph_present
|
|
25
|
+
assert_no_branching_events
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_application_machine
|
|
29
|
+
application_machine_path = "app/state_machines/application_machine.rb"
|
|
30
|
+
return if File.exist?(File.join(destination_root, application_machine_path))
|
|
31
|
+
|
|
32
|
+
template "application_machine.rb.tt", application_machine_path
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_machine_skeleton
|
|
36
|
+
template "machine_from_aasm.rb.tt", "app/state_machines/#{file_path}_flow.rb"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def mount_model
|
|
40
|
+
unless File.exist?(File.join(destination_root, model_file))
|
|
41
|
+
say_status :skip, "#{model_file} not found — mount the machine yourself: #{mounting_line.strip}", :yellow
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
inject_into_class model_file, class_name, mounting_line
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_log_migration
|
|
49
|
+
migration_template "create_log_table.rb.tt",
|
|
50
|
+
"#{migration_directory}/create_#{migration_slug}_transitions.rb"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def print_cleanup_instructions
|
|
54
|
+
say "\nOne migration: it creates the log table aasm never had and indexes " \
|
|
55
|
+
"the existing #{state_column} column.", :green
|
|
56
|
+
say "The column itself is left alone — NOT NULL, a default and a CHECK on a live"
|
|
57
|
+
say "table are locks and legacy-row explosions; the migration header carries the recipe."
|
|
58
|
+
say "\nAfter it runs, finish the move by hand:", :green
|
|
59
|
+
say " * #{class_name}: drop `include AASM` and the whole `aasm do ... end` block."
|
|
60
|
+
say " * Event calls keep their names with helpers: true (`order.pay!`), but a losing"
|
|
61
|
+
say " race now raises TransitionConflict instead of quietly answering false."
|
|
62
|
+
say " * aasm state predicates (`order.paid?`) are not generated: use `in_state?(:paid)`"
|
|
63
|
+
say " or the `#{class_name}.paid` scope."
|
|
64
|
+
say " * Guards that were lambdas are TODO comments in the skeleton — port them by hand."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def resolve_aasm_machine
|
|
70
|
+
unless model_class.respond_to?(:aasm)
|
|
71
|
+
raise Thor::Error, "#{class_name} does not respond to .aasm — " \
|
|
72
|
+
"point the generator at the model that includes AASM"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
machine = aasm_machine_name ? model_class.aasm(aasm_machine_name.to_sym) : model_class.aasm
|
|
76
|
+
unless machine.respond_to?(:states) && machine.respond_to?(:events)
|
|
77
|
+
raise Thor::Error, "#{class_name}.aasm does not look like an aasm machine (no .states/.events)"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
machine
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def model_class
|
|
84
|
+
@model_class ||= class_name.safe_constantize ||
|
|
85
|
+
raise(Thor::Error, "model class #{class_name} not found — " \
|
|
86
|
+
"the generator reads the live aasm machine off it")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A model with named machines answers the unnamed `.aasm` with the
|
|
90
|
+
# :default machine, which holds no states at all — a silently empty
|
|
91
|
+
# graph. Name the machines instead of generating a skeleton of nothing.
|
|
92
|
+
def assert_graph_present
|
|
93
|
+
return unless states.empty?
|
|
94
|
+
|
|
95
|
+
raise Thor::Error, "#{class_name}'s #{machine_label} declares no states#{named_machines_hint}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def named_machines_hint
|
|
99
|
+
names = declared_machine_names - ["default"]
|
|
100
|
+
return "" if names.empty?
|
|
101
|
+
|
|
102
|
+
" — it declares named machines: #{names.join(", ")}; " \
|
|
103
|
+
"pass one: rails g statecraft:from_aasm #{name} #{names.first}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def declared_machine_names
|
|
107
|
+
store = defined?(AASM::StateMachineStore) && AASM::StateMachineStore.fetch(model_class)
|
|
108
|
+
store.respond_to?(:machine_names) ? store.machine_names.map(&:to_s) : []
|
|
109
|
+
rescue StandardError
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# aasm picks the first transition of an event whose guard passes, so one
|
|
114
|
+
# event may branch from a single state; statecraft compiles an event as a
|
|
115
|
+
# partial function (from is unique within an event) and refuses such a
|
|
116
|
+
# graph. The split is a domain decision, so the door stops here.
|
|
117
|
+
def assert_no_branching_events
|
|
118
|
+
branching = event_descriptors.filter_map do |event|
|
|
119
|
+
froms = event[:transitions].map { |transition| transition[:from] }
|
|
120
|
+
duplicated = froms.tally.select { |_from, count| count > 1 }.keys
|
|
121
|
+
"#{event[:name]} (from #{duplicated.join(", ")})" unless duplicated.empty?
|
|
122
|
+
end
|
|
123
|
+
return if branching.empty?
|
|
124
|
+
|
|
125
|
+
raise Thor::Error,
|
|
126
|
+
"these aasm events branch from one state: #{branching.join("; ")}. " \
|
|
127
|
+
"statecraft makes an event a partial function — within one event, from is unique. " \
|
|
128
|
+
"Split each into two named events (the pay / fail_payment pattern) in aasm first, " \
|
|
129
|
+
"then rerun this generator."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def event_descriptors
|
|
133
|
+
@event_descriptors ||= @aasm_machine.events.map do |event|
|
|
134
|
+
{
|
|
135
|
+
name: event.name,
|
|
136
|
+
transitions: event.transitions.map { |transition| describe_transition(event, transition) }
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def describe_transition(event, transition)
|
|
142
|
+
options = transition.respond_to?(:opts) ? transition.opts : {}
|
|
143
|
+
{ from: transition.from, to: transition.to, guard: guard_descriptor(event, transition, options) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# aasm spells the record-level guard three ways and calls it on the
|
|
147
|
+
# record; statecraft's record_guard: is the same nature (arity 1) but
|
|
148
|
+
# resolves on the machine, so every symbol becomes a one-line delegate.
|
|
149
|
+
# A lambda carries its own closure and cannot be moved — it is reported.
|
|
150
|
+
def guard_descriptor(event, transition, options)
|
|
151
|
+
positive = options[:guard] || options[:if]
|
|
152
|
+
negative = options[:unless]
|
|
153
|
+
return symbol_guard(positive, negated: false) if positive.is_a?(Symbol)
|
|
154
|
+
return symbol_guard(negative, negated: true) if negative.is_a?(Symbol)
|
|
155
|
+
|
|
156
|
+
lambda_note(event, transition, positive || negative)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def symbol_guard(name, negated:)
|
|
160
|
+
{ kind: :delegate, source: name, method_name: negated ? "not_#{name.to_s.delete_suffix("?")}?" : name.to_s,
|
|
161
|
+
negated: negated }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def lambda_note(event, transition, callable)
|
|
165
|
+
return nil if callable.nil?
|
|
166
|
+
|
|
167
|
+
location = callable.respond_to?(:source_location) ? callable.source_location&.join(":") : nil
|
|
168
|
+
origin = location ? " — defined at #{location}" : ""
|
|
169
|
+
{ kind: :todo, note: "#{event.name} (#{transition.from} -> #{transition.to})#{origin}" }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def states
|
|
173
|
+
@states ||= @aasm_machine.states.map { |state| state.name.to_s }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def initial_state
|
|
177
|
+
@aasm_machine.initial_state.to_s
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def events
|
|
181
|
+
event_descriptors.flat_map do |event|
|
|
182
|
+
event[:transitions].map do |transition|
|
|
183
|
+
{ name: event[:name], from: transition[:from], to: transition[:to],
|
|
184
|
+
guard: delegate_guard(transition) }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def guard_delegates
|
|
190
|
+
events.filter_map { |event| event[:guard] }.uniq { |guard| guard[:method_name] }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def guard_todos
|
|
194
|
+
all_guards.filter_map { |guard| guard[:note] if guard[:kind] == :todo }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def delegate_guard(transition)
|
|
198
|
+
guard = transition[:guard]
|
|
199
|
+
guard if guard && guard[:kind] == :delegate
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def all_guards
|
|
203
|
+
event_descriptors.flat_map { |event| event[:transitions].filter_map { |t| t[:guard] } }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def state_column
|
|
207
|
+
@aasm_machine.respond_to?(:attribute_name) ? @aasm_machine.attribute_name.to_s : "state"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def machine_label
|
|
211
|
+
aasm_machine_name ? "aasm machine #{aasm_machine_name}" : "unnamed aasm machine"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def mounting_line
|
|
215
|
+
column = state_column == "state" ? "" : "column: :#{state_column}, "
|
|
216
|
+
" state_machine #{class_name}Flow, #{column}changed_at: true, helpers: true, scopes: true\n"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def model_file
|
|
220
|
+
"app/models/#{file_path}.rb"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def table_name
|
|
224
|
+
@table_name ||= model_class.respond_to?(:table_name) ? model_class.table_name : super
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def log_table_name
|
|
228
|
+
"#{table_name.singularize}_transitions"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def foreign_key_column
|
|
232
|
+
"#{file_name}_id"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def migration_directory
|
|
236
|
+
db_config = model_class.respond_to?(:connection_db_config) && model_class.connection_db_config
|
|
237
|
+
configured = db_config && Array(db_config.migrations_paths).first
|
|
238
|
+
configured || "db/migrate"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def migration_slug
|
|
242
|
+
file_path.tr("/", "_")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def migration_class_name
|
|
246
|
+
"Create#{migration_slug.camelize}Transitions"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared home for private guard helpers used by several machines. Keep it
|
|
4
|
+
# free of DSL declarations (states, transitions, events): machine definitions
|
|
5
|
+
# are NOT inherited — each machine declares its own graph.
|
|
6
|
+
class ApplicationMachine
|
|
7
|
+
include Statecraft::Machine
|
|
8
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# aasm kept the state in <%= table_name %>.<%= state_column %> and kept no history at
|
|
4
|
+
# all, so this migration adds only what the gem needs: the append-only log and
|
|
5
|
+
# an index on the column the scopes will read.
|
|
6
|
+
#
|
|
7
|
+
# The column itself is deliberately left as aasm made it. To tighten it later
|
|
8
|
+
# without blocking writes on a live table:
|
|
9
|
+
# ALTER TABLE <%= table_name %> ALTER COLUMN <%= state_column %> SET DEFAULT '<%= initial_state %>';
|
|
10
|
+
# -- backfill NULLs, then --
|
|
11
|
+
# ALTER TABLE <%= table_name %> ALTER COLUMN <%= state_column %> SET NOT NULL;
|
|
12
|
+
# ALTER TABLE <%= table_name %> ADD CONSTRAINT <%= table_name %>_<%= state_column %>_check
|
|
13
|
+
# CHECK (<%= state_column %> IN (<%= states.map { |state| "'#{state}'" }.join(", ") %>)) NOT VALID;
|
|
14
|
+
# -- clean the data --
|
|
15
|
+
# ALTER TABLE <%= table_name %> VALIDATE CONSTRAINT <%= table_name %>_<%= state_column %>_check;
|
|
16
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
17
|
+
def change
|
|
18
|
+
create_table :<%= log_table_name.pluralize %> do |t|
|
|
19
|
+
t.references :<%= file_name %>,
|
|
20
|
+
null: false, index: false,
|
|
21
|
+
foreign_key: { to_table: :<%= table_name %>, on_delete: :cascade }
|
|
22
|
+
t.string :from_state, null: false
|
|
23
|
+
t.string :to_state, null: false
|
|
24
|
+
t.string :event
|
|
25
|
+
t.column :metadata, metadata_column_type, null: false, default: {}
|
|
26
|
+
t.datetime :created_at, null: false
|
|
27
|
+
t.index %i[<%= foreign_key_column %> id]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
add_index :<%= table_name %>, :<%= state_column %> unless index_exists?(:<%= table_name %>, :<%= state_column %>)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# jsonb where the database has it, json elsewhere: the adapter is known
|
|
36
|
+
# only when the migration actually runs, never at generation time.
|
|
37
|
+
def metadata_column_type
|
|
38
|
+
connection.adapter_name.match?(/postg/i) ? :jsonb : :json
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Skeleton generated from <%= class_name %>'s <%= machine_label %>: the graph and
|
|
4
|
+
# the event names are copied, the semantics are yours to port.
|
|
5
|
+
#
|
|
6
|
+
# - aasm guards judge the record and see no input, so every symbol guard came
|
|
7
|
+
# over as record_guard: with a one-line delegate below. A guard that reads
|
|
8
|
+
# what the caller submits belongs on guard: (arity 2) instead.
|
|
9
|
+
# - lambda guards carry their own closure and cannot be moved; each is a TODO
|
|
10
|
+
# with its original location.
|
|
11
|
+
class <%= class_name %>Flow < ApplicationMachine
|
|
12
|
+
<% states.each do |state_name| -%>
|
|
13
|
+
state :<%= state_name %><%= state_name == initial_state ? ", initial: true" : "" %>
|
|
14
|
+
<% end -%>
|
|
15
|
+
|
|
16
|
+
<% events.each do |event| -%>
|
|
17
|
+
event :<%= event[:name] %>, from: :<%= event[:from] %>, to: :<%= event[:to] %><%= event[:guard] ? ", record_guard: :#{event[:guard][:method_name]}" : "" %>
|
|
18
|
+
<% end -%>
|
|
19
|
+
<% unless guard_todos.empty? -%>
|
|
20
|
+
|
|
21
|
+
<% guard_todos.each do |note| -%>
|
|
22
|
+
# TODO(guard): <%= note %>
|
|
23
|
+
<% end -%>
|
|
24
|
+
<% end -%>
|
|
25
|
+
<% unless guard_delegates.empty? -%>
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The machine keeps the registry "event -> predicate" and delegates the
|
|
30
|
+
# domain facts to the record, exactly where aasm called them.
|
|
31
|
+
<% guard_delegates.each do |guard| -%>
|
|
32
|
+
def <%= guard[:method_name] %>(record) = <%= guard[:negated] ? "!record.#{guard[:source]}" : "record.#{guard[:source]}" %>
|
|
33
|
+
<% end -%>
|
|
34
|
+
<% end -%>
|
|
35
|
+
end
|
data/lib/statecraft/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: statecraft
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -60,6 +60,11 @@ extra_rdoc_files: []
|
|
|
60
60
|
files:
|
|
61
61
|
- LICENSE.txt
|
|
62
62
|
- README.md
|
|
63
|
+
- lib/generators/statecraft/from_aasm/USAGE
|
|
64
|
+
- lib/generators/statecraft/from_aasm/from_aasm_generator.rb
|
|
65
|
+
- lib/generators/statecraft/from_aasm/templates/application_machine.rb.tt
|
|
66
|
+
- lib/generators/statecraft/from_aasm/templates/create_log_table.rb.tt
|
|
67
|
+
- lib/generators/statecraft/from_aasm/templates/machine_from_aasm.rb.tt
|
|
63
68
|
- lib/generators/statecraft/from_statesman/USAGE
|
|
64
69
|
- lib/generators/statecraft/from_statesman/from_statesman_generator.rb
|
|
65
70
|
- lib/generators/statecraft/from_statesman/templates/application_machine.rb.tt
|