sequel-state-machine 1.0.0 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
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.
|