sequel-state-machine 1.0.0 → 1.2.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
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3f0c0bd5c716d726e9e8ba679d545038637a3ed7da8a569918e24725d0558021
|
4
|
+
data.tar.gz: dcba20e8cb5eb3fbcbad3e70e5e5d0662d3515c44ebb6fa033efc5ae4f1e7a02
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 78367e0296561bc60d3474dad00650cf1865bd50ee8c96eef1e430c24da24b47ab9f2a70c21f2560a1417ec014b0437e4cbc374c276ab8f18a1f162f4e1dfe5c
|
7
|
+
data.tar.gz: 48c59a255b6d6c4736ceed4e75ab8f0bfe59363cab146ae989c2928d9560f829ea62da3cae3452a1006a6e19a7c8600ced96847ab9f453f74cc854cf51c39841
|
@@ -2,56 +2,119 @@
|
|
2
2
|
|
3
3
|
require "sequel"
|
4
4
|
require "sequel/model"
|
5
|
+
require "set"
|
5
6
|
require "state_machines/sequel"
|
6
7
|
|
7
8
|
module Sequel
|
8
9
|
module Plugins
|
9
10
|
module StateMachine
|
11
|
+
class InvalidConfiguration < RuntimeError; end
|
12
|
+
|
13
|
+
def self.apply(_model, _opts={})
|
14
|
+
nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.configure(model, opts={})
|
18
|
+
(opts = {status_column: opts}) if opts.is_a?(Symbol)
|
19
|
+
model.instance_eval do
|
20
|
+
# See state_machine_status_column.
|
21
|
+
# We must defer defaulting the value in case the plugin
|
22
|
+
# comes ahead of the state machine (we can see a valid state machine,
|
23
|
+
# but the attribute/name will always be :state, due to some configuration
|
24
|
+
# order of operations).
|
25
|
+
@sequel_state_machine_status_column = opts[:status_column] if opts[:status_column]
|
26
|
+
@sequel_state_machine_audit_logs_association = opts[:audit_logs_association] || :audit_logs
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
10
30
|
module InstanceMethods
|
31
|
+
private def state_machine_status_column(machine=nil)
|
32
|
+
return machine if machine
|
33
|
+
col = self.class.instance_variable_get(:@sequel_state_machine_status_column)
|
34
|
+
return col unless col.nil?
|
35
|
+
if self.respond_to?(:_state_value_attr)
|
36
|
+
self.class.instance_variable_set(:@sequel_state_machine_status_column, self._state_value_attr)
|
37
|
+
return self._state_value_attr
|
38
|
+
end
|
39
|
+
if !self.class.respond_to?(:state_machines) || self.class.state_machines.empty?
|
40
|
+
msg = "Model must extend StateMachines::MacroMethods and have one state_machine."
|
41
|
+
raise InvalidConfiguration, msg
|
42
|
+
end
|
43
|
+
if self.class.state_machines.length > 1 && machine.nil?
|
44
|
+
msg = "You must provide the :machine keyword argument when working multiple state machines."
|
45
|
+
raise ArgumentError, msg
|
46
|
+
end
|
47
|
+
self.class.instance_variable_set(:@sequel_state_machine_status_column, self.class.state_machine.attribute)
|
48
|
+
end
|
49
|
+
|
50
|
+
def sequel_state_machine_status(machine=nil)
|
51
|
+
return self.send(self.state_machine_status_column(machine))
|
52
|
+
end
|
53
|
+
|
11
54
|
def new_audit_log
|
12
|
-
|
13
|
-
|
14
|
-
|
55
|
+
assoc_name = self.class.instance_variable_get(:@sequel_state_machine_audit_logs_association)
|
56
|
+
unless (audit_log_assoc = self.class.association_reflections[assoc_name])
|
57
|
+
msg = "Association for audit logs '#{assoc_name}' does not exist. " \
|
58
|
+
"Your model must have 'one_to_many :audit_logs' for its audit log lines, " \
|
59
|
+
"or pass the :audit_logs_association parameter to the plugin to define its association name."
|
60
|
+
raise InvalidConfiguration, msg
|
61
|
+
end
|
62
|
+
audit_log_cls = audit_log_assoc[:class] || Kernel.const_get(audit_log_assoc[:class_name])
|
63
|
+
return audit_log_cls.new
|
15
64
|
end
|
16
65
|
|
17
|
-
def current_audit_log
|
18
|
-
|
66
|
+
def current_audit_log(machine: nil)
|
67
|
+
@current_audit_logs ||= {}
|
68
|
+
alog = @current_audit_logs[machine]
|
69
|
+
if alog.nil?
|
19
70
|
StateMachines::Sequel.log(self, :debug, "preparing_audit_log", {})
|
20
|
-
|
21
|
-
|
71
|
+
alog = self.new_audit_log
|
72
|
+
if machine
|
73
|
+
machine_name_col = alog.class.state_machine_column_mappings[:machine_name]
|
74
|
+
unless alog.respond_to?(machine_name_col)
|
75
|
+
msg = "Audit logs must have a :machine_name field for multi-machine models or if specifying :machine."
|
76
|
+
raise InvalidConfiguration, msg
|
77
|
+
end
|
78
|
+
alog.sequel_state_machine_set(:machine_name, machine)
|
79
|
+
end
|
80
|
+
@current_audit_logs[machine] = alog
|
81
|
+
alog.sequel_state_machine_set(:reason, "")
|
22
82
|
end
|
23
|
-
return
|
83
|
+
return alog
|
24
84
|
end
|
25
85
|
|
26
86
|
def commit_audit_log(transition)
|
27
|
-
|
28
|
-
|
87
|
+
machine = self.class.state_machines.length > 1 ? transition.machine.name : nil
|
88
|
+
StateMachines::Sequel.log(
|
89
|
+
self, :debug, "committing_audit_log", {transition: transition, state_machine: machine},
|
90
|
+
)
|
91
|
+
current = self.current_audit_log(machine: machine)
|
29
92
|
|
30
93
|
last_saved = self.audit_logs.find do |a|
|
31
|
-
a.event == transition.event.to_s &&
|
32
|
-
a.from_state == transition.from &&
|
33
|
-
a.to_state == transition.to
|
94
|
+
a.sequel_state_machine_get(:event) == transition.event.to_s &&
|
95
|
+
a.sequel_state_machine_get(:from_state) == transition.from &&
|
96
|
+
a.sequel_state_machine_get(:to_state) == transition.to
|
34
97
|
end
|
35
98
|
if last_saved
|
36
99
|
StateMachines::Sequel.log(self, :debug, "updating_audit_log", {audit_log_id: last_saved.id})
|
37
|
-
last_saved.update(
|
100
|
+
last_saved.update(**last_saved.sequel_state_machine_map_columns(
|
38
101
|
at: Time.now,
|
39
102
|
actor: StateMachines::Sequel.current_actor,
|
40
103
|
messages: current.messages,
|
41
104
|
reason: current.reason,
|
42
|
-
)
|
105
|
+
))
|
43
106
|
else
|
44
107
|
StateMachines::Sequel.log(self, :debug, "creating_audit_log", {})
|
45
|
-
current.set(
|
108
|
+
current.set(**current.sequel_state_machine_map_columns(
|
46
109
|
at: Time.now,
|
47
110
|
actor: StateMachines::Sequel.current_actor,
|
48
111
|
event: transition.event.to_s,
|
49
112
|
from_state: transition.from,
|
50
113
|
to_state: transition.to,
|
51
|
-
)
|
114
|
+
))
|
52
115
|
self.add_audit_log(current)
|
53
116
|
end
|
54
|
-
@
|
117
|
+
@current_audit_logs[machine] = nil
|
55
118
|
end
|
56
119
|
|
57
120
|
def audit(message, reason: nil)
|
@@ -64,21 +127,31 @@ module Sequel
|
|
64
127
|
audlog.messages += (audlog.messages.empty? ? message : (message + "\n"))
|
65
128
|
end
|
66
129
|
audlog.reason = reason if reason
|
130
|
+
return audlog
|
67
131
|
end
|
68
132
|
|
69
|
-
def audit_one_off(event, messages, reason: nil)
|
133
|
+
def audit_one_off(event, messages, reason: nil, machine: nil)
|
70
134
|
messages = [messages] unless messages.respond_to?(:to_ary)
|
71
135
|
audlog = self.new_audit_log
|
72
|
-
audlog.
|
136
|
+
mapped_values = audlog.sequel_state_machine_map_columns(
|
73
137
|
at: Time.now,
|
74
138
|
event: event,
|
75
|
-
from_state: self.
|
76
|
-
to_state: self.
|
139
|
+
from_state: self.sequel_state_machine_status(machine),
|
140
|
+
to_state: self.sequel_state_machine_status(machine),
|
77
141
|
messages: audlog.class.state_machine_messages_supports_array ? messages : messages.join("\n"),
|
78
142
|
reason: reason || "",
|
79
143
|
actor: StateMachines::Sequel.current_actor,
|
144
|
+
machine_name: machine,
|
80
145
|
)
|
81
|
-
|
146
|
+
audlog.set(mapped_values)
|
147
|
+
return self.add_audit_log(audlog)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Return audit logs for the given state machine name.
|
151
|
+
# Only useful for multi-state-machine models.
|
152
|
+
def audit_logs_for(machine)
|
153
|
+
lines = self.send(self.class.instance_variable_get(:@sequel_state_machine_audit_logs_association))
|
154
|
+
return lines.select { |ln| ln.sequel_state_machine_get(:machine_name) == machine.to_s }
|
82
155
|
end
|
83
156
|
|
84
157
|
# Send event with arguments inside of a transaction, save the changes to the receiver,
|
@@ -113,26 +186,39 @@ module Sequel
|
|
113
186
|
end
|
114
187
|
|
115
188
|
# Return true if the given event can be transitioned into by the current state.
|
116
|
-
def valid_state_path_through?(event)
|
117
|
-
|
118
|
-
|
189
|
+
def valid_state_path_through?(event, machine: nil)
|
190
|
+
current_state_str = self.sequel_state_machine_status(machine).to_s
|
191
|
+
current_state_sym = current_state_str.to_sym
|
192
|
+
sm = find_state_machine(machine)
|
193
|
+
event_obj = sm.events[event] or
|
194
|
+
raise ArgumentError, "Invalid event #{event} (available #{sm.name} events: #{sm.events.keys.join(', ')})"
|
119
195
|
event_obj.branches.each do |branch|
|
120
196
|
branch.state_requirements.each do |state_req|
|
121
|
-
|
197
|
+
next unless (from = state_req[:from])
|
198
|
+
return true if from.matches?(current_state_str) || from.matches?(current_state_sym)
|
122
199
|
end
|
123
200
|
end
|
124
201
|
return false
|
125
202
|
end
|
126
203
|
|
127
|
-
def
|
128
|
-
|
204
|
+
def validates_state_machine(machine: nil)
|
205
|
+
state_machine = find_state_machine(machine)
|
206
|
+
states = state_machine.states.map(&:value)
|
207
|
+
state = self.sequel_state_machine_status(state_machine.attribute)
|
208
|
+
return if states.include?(state)
|
209
|
+
self.errors.add(self.state_machine_status_column(machine),
|
210
|
+
"state '#{state}' must be one of (#{states.sort.join(', ')})",)
|
129
211
|
end
|
130
212
|
|
131
|
-
def
|
132
|
-
|
133
|
-
state
|
134
|
-
|
135
|
-
|
213
|
+
private def find_state_machine(machine)
|
214
|
+
machines = self.class.state_machines
|
215
|
+
raise InvalidConfiguration, "no state machines defined" if machines.empty?
|
216
|
+
if machine
|
217
|
+
m = machines[machine]
|
218
|
+
raise ArgumentError, "no state machine named #{machine}" if m.nil?
|
219
|
+
return m
|
220
|
+
end
|
221
|
+
return machines.first[1]
|
136
222
|
end
|
137
223
|
end
|
138
224
|
|
@@ -6,26 +6,39 @@ require "sequel/model"
|
|
6
6
|
module Sequel
|
7
7
|
module Plugins
|
8
8
|
module StateMachineAuditLog
|
9
|
+
DEFAULT_COLUMN_MAPPINGS = {
|
10
|
+
at: :at,
|
11
|
+
event: :event,
|
12
|
+
to_state: :to_state,
|
13
|
+
from_state: :from_state,
|
14
|
+
reason: :reason,
|
15
|
+
messages: :messages,
|
16
|
+
actor_id: :actor_id,
|
17
|
+
actor: :actor,
|
18
|
+
machine_name: :machine_name,
|
19
|
+
}.freeze
|
9
20
|
DEFAULT_OPTIONS = {
|
10
21
|
messages_supports_array: :undefined,
|
11
|
-
column_mappings:
|
12
|
-
at: :at,
|
13
|
-
event: :event,
|
14
|
-
to_state: :to_state,
|
15
|
-
from_state: :from_state,
|
16
|
-
reason: :reason,
|
17
|
-
messages: :messages,
|
18
|
-
actor_id: :actor_id,
|
19
|
-
}.freeze,
|
22
|
+
column_mappings: DEFAULT_COLUMN_MAPPINGS,
|
20
23
|
}.freeze
|
21
24
|
def self.configure(model, opts=DEFAULT_OPTIONS)
|
22
25
|
opts = DEFAULT_OPTIONS.merge(opts)
|
23
|
-
|
26
|
+
colmap = opts[:column_mappings]
|
27
|
+
actor_key_mismatch = (colmap.key?(:actor) && !colmap.key?(:actor_id)) ||
|
28
|
+
(!colmap.key?(:actor) && colmap.key?(:actor_id))
|
29
|
+
if actor_key_mismatch
|
30
|
+
msg = "Remapping columns :actor and :actor_id must both be supplied"
|
31
|
+
raise Sequel::Plugins::StateMachine::InvalidConfiguration, msg
|
32
|
+
end
|
33
|
+
colmap = DEFAULT_COLUMN_MAPPINGS.merge(colmap)
|
34
|
+
model.state_machine_column_mappings = colmap
|
24
35
|
msgarray = opts[:messages_supports_array] || true
|
25
36
|
if msgarray == :undefined
|
26
37
|
msgcol = model.state_machine_column_mappings[:messages]
|
27
|
-
|
28
|
-
|
38
|
+
if model.db_schema && model.db_schema[msgcol]
|
39
|
+
dbt = model.db_schema[msgcol][:db_type]
|
40
|
+
msgarray = dbt.include?("json") || dbt.include?("[]")
|
41
|
+
end
|
29
42
|
end
|
30
43
|
model.state_machine_messages_supports_array = msgarray
|
31
44
|
end
|
@@ -36,14 +49,14 @@ module Sequel
|
|
36
49
|
|
37
50
|
module DatasetMethods
|
38
51
|
def failed
|
39
|
-
colmap = self.state_machine_column_mappings
|
52
|
+
colmap = self.model.state_machine_column_mappings
|
40
53
|
tostate_col = colmap[:to_state]
|
41
54
|
fromstate_col = colmap[:from_state]
|
42
55
|
return self.where(tostate_col => fromstate_col)
|
43
56
|
end
|
44
57
|
|
45
58
|
def succeeded
|
46
|
-
colmap = self.state_machine_column_mappings
|
59
|
+
colmap = self.model.state_machine_column_mappings
|
47
60
|
tostate_col = colmap[:to_state]
|
48
61
|
fromstate_col = colmap[:from_state]
|
49
62
|
return self.exclude(tostate_col => fromstate_col)
|
@@ -52,13 +65,35 @@ module Sequel
|
|
52
65
|
|
53
66
|
module InstanceMethods
|
54
67
|
def failed?
|
55
|
-
|
56
|
-
|
68
|
+
from_state = self.sequel_state_machine_get(:from_state)
|
69
|
+
to_state = self.sequel_state_machine_get(:to_state)
|
70
|
+
return from_state == to_state
|
57
71
|
end
|
58
72
|
|
59
73
|
def succeeded?
|
60
|
-
|
61
|
-
|
74
|
+
return !self.failed?
|
75
|
+
end
|
76
|
+
|
77
|
+
def full_message
|
78
|
+
msg = self.sequel_state_machine_get(:messages)
|
79
|
+
return self.class.state_machine_messages_supports_array ? msg.join(", ") : msg
|
80
|
+
end
|
81
|
+
|
82
|
+
def sequel_state_machine_get(unmapped)
|
83
|
+
return self[self.class.state_machine_column_mappings[unmapped]]
|
84
|
+
end
|
85
|
+
|
86
|
+
def sequel_state_machine_set(unmapped, value)
|
87
|
+
self[self.class.state_machine_column_mappings[unmapped]] = value
|
88
|
+
end
|
89
|
+
|
90
|
+
def sequel_state_machine_map_columns(**kw)
|
91
|
+
# We may pass in a machine_name of nil when we are using single-state-machine models.
|
92
|
+
# In this case, we assume the audit logs don't have a machine_name column,
|
93
|
+
# so always remove the column if the machine is nil.
|
94
|
+
kw.delete(:machine_name) if kw[:machine_name].nil?
|
95
|
+
mappings = self.class.state_machine_column_mappings
|
96
|
+
return kw.transform_keys { |k| mappings[k] or raise KeyError, "field #{k} unmapped in #{mappings}" }
|
62
97
|
end
|
63
98
|
end
|
64
99
|
end
|
@@ -4,9 +4,15 @@ require "rspec"
|
|
4
4
|
|
5
5
|
RSpec::Matchers.define :transition_on do |event|
|
6
6
|
match do |receiver|
|
7
|
-
raise
|
7
|
+
raise ArgumentError, "must use :to to provide a target state" if (@to || "").to_s.empty?
|
8
|
+
raise ArgumentError, "must use :of_machine for use with multiple state machines" if
|
9
|
+
@machine.nil? && receiver.class.state_machines.length > 1
|
8
10
|
receiver.send(event, *@args)
|
9
|
-
@to == receiver.
|
11
|
+
@to == receiver.send(@machine || :sequel_state_machine_status)
|
12
|
+
end
|
13
|
+
|
14
|
+
chain :of_machine do |col|
|
15
|
+
@machine = col
|
10
16
|
end
|
11
17
|
|
12
18
|
chain :to do |to_state|
|
@@ -23,10 +29,10 @@ RSpec::Matchers.define :transition_on do |event|
|
|
23
29
|
|
24
30
|
failure_message do |receiver|
|
25
31
|
msg =
|
26
|
-
if @to == receiver.
|
32
|
+
if @to == receiver.state_machine_status
|
27
33
|
"expected that event #{event} would transition, but did not"
|
28
34
|
else
|
29
|
-
"expected that event #{event} would transition to #{@to} but is #{receiver.
|
35
|
+
"expected that event #{event} would transition to #{@to} but is #{receiver.state_machine_status}"
|
30
36
|
end
|
31
37
|
(msg += "\n#{receiver.audit_logs.map(&:inspect).join("\n")}") if @audit
|
32
38
|
msg
|
@@ -43,11 +49,12 @@ RSpec::Matchers.define :not_transition_on do |event|
|
|
43
49
|
end
|
44
50
|
|
45
51
|
failure_message do |receiver|
|
46
|
-
"expected that event #{event} would not transition, but did and is now #{receiver.
|
52
|
+
"expected that event #{event} would not transition, but did and is now #{receiver.state_machine_status}"
|
47
53
|
end
|
48
54
|
end
|
49
55
|
|
50
56
|
RSpec.shared_examples "a state machine with audit logging" do |event, to_state|
|
57
|
+
let(:machine) { raise NotImplementedError, "must override let(:machine)" }
|
51
58
|
it "logs transitions" do
|
52
59
|
expect(machine).to transition_on(event).to(to_state)
|
53
60
|
expect(machine.audit_logs).to contain_exactly(
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "state_machines"
|
4
|
+
|
3
5
|
module StateMachines
|
4
6
|
module Sequel
|
5
7
|
class CurrentActorAlreadySet < StateMachines::Error; end
|
@@ -11,11 +13,12 @@ module StateMachines
|
|
11
13
|
@event = event
|
12
14
|
@object = obj
|
13
15
|
msg = "#{obj.class}[#{obj.id}] failed to transition on #{event}"
|
14
|
-
if obj.respond_to?(:audit_logs) && obj.audit_logs.
|
16
|
+
if obj.respond_to?(:audit_logs) && !obj.audit_logs.empty?
|
15
17
|
@audit_log = obj.audit_logs.max_by(&:id)
|
16
|
-
msg += ": #{@audit_log.
|
18
|
+
msg += ": #{@audit_log.full_message}"
|
19
|
+
msg = msg.tr("\n", " ").strip
|
17
20
|
end
|
18
|
-
super(msg)
|
21
|
+
super(obj, msg)
|
19
22
|
end
|
20
23
|
end
|
21
24
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sequel-state-machine
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Lithic Tech
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-04-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec
|
@@ -94,6 +94,20 @@ dependencies:
|
|
94
94
|
- - "~>"
|
95
95
|
- !ruby/object:Gem::Version
|
96
96
|
version: '5.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: simplecov
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
97
111
|
- !ruby/object:Gem::Dependency
|
98
112
|
name: sqlite3
|
99
113
|
requirement: !ruby/object:Gem::Requirement
|
@@ -122,6 +136,20 @@ dependencies:
|
|
122
136
|
- - "~>"
|
123
137
|
- !ruby/object:Gem::Version
|
124
138
|
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: timecop
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
125
153
|
description: |
|
126
154
|
sequel-state-machine hooks together the excellent Ruby Sequel ORM to
|
127
155
|
the state-machines library, with auditing and other tools.
|