sequel-state-machine 1.1.0 → 1.2.2

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: 32bf94c0e52e6d2fcaf7593f02f4834f689ee462a378dfc7d561e3e29db79258
4
- data.tar.gz: a99a5e2ccd052cecd228ccc7c702bde70b675b86afac2fbf2b69d95432a9b8b5
3
+ metadata.gz: 94b067e5d260a2c467a1f1b8b4ec206054c29ab7fc54a86d2b5a89cc7a6620b1
4
+ data.tar.gz: aea214e6ab04cb78cffc4d580ae1de70011d9f0052562aa3649c217447d9ae0b
5
5
  SHA512:
6
- metadata.gz: 10d259cf6ff25cdabe78ef6699ff6fa9be62de332cd9c45901b5295be687159cbf10e7738426ab94c0f2f4f2c0af3cccf44d3ba371c026f30e00bb6677910747
7
- data.tar.gz: 349b9378af67e386b705d9d1d6e26ca2a48ff953b322f1a47dc40da308ab240e289d1042b53acbaaf2525df408a9c05be5fd62c33b4986b28452a4d51351ced5
6
+ metadata.gz: 9e8ee131d5e6da8495c7fb998ca81fbfdb43f8a6b7feb52bb919011e51d2d79c38ca190ba12fbf3d866d4aaf0484ae5db7601f903aec162736abdea1f6f05187
7
+ data.tar.gz: de91701d5ae07856d516c64963cfb988b12333c1e2bcc929f29d37cabad1cc779929cc9d07216fd6af6afb8780e19a2714b40dbb497918b32ca0e1971ce0cd64
@@ -2,6 +2,7 @@
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
@@ -14,19 +15,21 @@ module Sequel
14
15
  end
15
16
 
16
17
  def self.configure(model, opts={})
17
- col = opts.is_a?(Symbol) ? opts : nil
18
+ (opts = {status_column: opts}) if opts.is_a?(Symbol)
18
19
  model.instance_eval do
19
20
  # See state_machine_status_column.
20
21
  # We must defer defaulting the value in case the plugin
21
22
  # comes ahead of the state machine (we can see a valid state machine,
22
23
  # but the attribute/name will always be :state, due to some configuration
23
24
  # order of operations).
24
- @sequel_state_machine_status_column = col if col
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
25
27
  end
26
28
  end
27
29
 
28
30
  module InstanceMethods
29
- private def state_machine_status_column
31
+ private def state_machine_status_column(machine=nil)
32
+ return machine if machine
30
33
  col = self.class.instance_variable_get(:@sequel_state_machine_status_column)
31
34
  return col unless col.nil?
32
35
  if self.respond_to?(:_state_value_attr)
@@ -37,71 +40,85 @@ module Sequel
37
40
  msg = "Model must extend StateMachines::MacroMethods and have one state_machine."
38
41
  raise InvalidConfiguration, msg
39
42
  end
40
- if self.class.state_machines.length > 1
41
- msg = "Cannot use sequel-state-machine with multiple state machines. " \
42
- "Please file an issue at https://github.com/lithictech/sequel-state-machine/issues " \
43
- "if you need this capability."
44
- raise InvalidConfiguration, msg
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
45
46
  end
46
47
  self.class.instance_variable_set(:@sequel_state_machine_status_column, self.class.state_machine.attribute)
47
48
  end
48
49
 
49
- private def state_machine_status
50
- return self.send(self.state_machine_status_column)
51
- end
52
-
53
- def sequel_state_machine_status
54
- return state_machine_status
50
+ def sequel_state_machine_status(machine=nil)
51
+ return self.send(self.state_machine_status_column(machine))
55
52
  end
56
53
 
57
54
  def new_audit_log
58
- audit_log_assoc = self.class.association_reflections[:audit_logs]
59
- model = Kernel.const_get(audit_log_assoc[:class_name])
60
- return model.new
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
61
64
  end
62
65
 
63
- def current_audit_log
64
- if @current_audit_log.nil?
66
+ def current_audit_log(machine: nil)
67
+ @current_audit_logs ||= {}
68
+ alog = @current_audit_logs[machine]
69
+ if alog.nil?
65
70
  StateMachines::Sequel.log(self, :debug, "preparing_audit_log", {})
66
- @current_audit_log = self.new_audit_log
67
- @current_audit_log.reason = ""
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, "")
68
82
  end
69
- return @current_audit_log
83
+ return alog
70
84
  end
71
85
 
72
86
  def commit_audit_log(transition)
73
- StateMachines::Sequel.log(self, :debug, "committing_audit_log", {transition: transition})
74
- current = self.current_audit_log
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)
75
92
 
76
93
  last_saved = self.audit_logs.find do |a|
77
- a.event == transition.event.to_s &&
78
- a.from_state == transition.from &&
79
- 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
80
97
  end
81
98
  if last_saved
82
99
  StateMachines::Sequel.log(self, :debug, "updating_audit_log", {audit_log_id: last_saved.id})
83
- last_saved.update(
100
+ last_saved.update(**last_saved.sequel_state_machine_map_columns(
84
101
  at: Time.now,
85
102
  actor: StateMachines::Sequel.current_actor,
86
103
  messages: current.messages,
87
104
  reason: current.reason,
88
- )
105
+ ))
89
106
  else
90
107
  StateMachines::Sequel.log(self, :debug, "creating_audit_log", {})
91
- current.set(
108
+ current.set(**current.sequel_state_machine_map_columns(
92
109
  at: Time.now,
93
110
  actor: StateMachines::Sequel.current_actor,
94
111
  event: transition.event.to_s,
95
112
  from_state: transition.from,
96
113
  to_state: transition.to,
97
- )
114
+ ))
98
115
  self.add_audit_log(current)
99
116
  end
100
- @current_audit_log = nil
117
+ @current_audit_logs[machine] = nil
101
118
  end
102
119
 
103
- def audit(message, reason: nil)
104
- audlog = self.current_audit_log
120
+ def audit(message, reason: nil, machine: nil)
121
+ audlog = self.current_audit_log(machine: machine)
105
122
  if audlog.class.state_machine_messages_supports_array
106
123
  audlog.messages ||= []
107
124
  audlog.messages << message
@@ -110,21 +127,31 @@ module Sequel
110
127
  audlog.messages += (audlog.messages.empty? ? message : (message + "\n"))
111
128
  end
112
129
  audlog.reason = reason if reason
130
+ return audlog
113
131
  end
114
132
 
115
- def audit_one_off(event, messages, reason: nil)
133
+ def audit_one_off(event, messages, reason: nil, machine: nil)
116
134
  messages = [messages] unless messages.respond_to?(:to_ary)
117
135
  audlog = self.new_audit_log
118
- audlog.set(
136
+ mapped_values = audlog.sequel_state_machine_map_columns(
119
137
  at: Time.now,
120
138
  event: event,
121
- from_state: self.state_machine_status,
122
- to_state: self.state_machine_status,
139
+ from_state: self.sequel_state_machine_status(machine),
140
+ to_state: self.sequel_state_machine_status(machine),
123
141
  messages: audlog.class.state_machine_messages_supports_array ? messages : messages.join("\n"),
124
142
  reason: reason || "",
125
143
  actor: StateMachines::Sequel.current_actor,
144
+ machine_name: machine,
126
145
  )
127
- self.add_audit_log(audlog)
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 }
128
155
  end
129
156
 
130
157
  # Send event with arguments inside of a transaction, save the changes to the receiver,
@@ -159,10 +186,12 @@ module Sequel
159
186
  end
160
187
 
161
188
  # Return true if the given event can be transitioned into by the current state.
162
- def valid_state_path_through?(event)
163
- current_state_str = self.state_machine_status.to_s
189
+ def valid_state_path_through?(event, machine: nil)
190
+ current_state_str = self.sequel_state_machine_status(machine).to_s
164
191
  current_state_sym = current_state_str.to_sym
165
- event_obj = self.class.state_machine.events[event] or raise "Invalid event #{event}"
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(', ')})"
166
195
  event_obj.branches.each do |branch|
167
196
  branch.state_requirements.each do |state_req|
168
197
  next unless (from = state_req[:from])
@@ -172,13 +201,25 @@ module Sequel
172
201
  return false
173
202
  end
174
203
 
175
- def validates_state_machine
176
- states = self.class.state_machine.states.map(&:value)
177
- state = self.state_machine_status
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)
178
208
  return if states.include?(state)
179
- self.errors.add(self.state_machine_status_column,
209
+ self.errors.add(self.state_machine_status_column(machine),
180
210
  "state '#{state}' must be one of (#{states.sort.join(', ')})",)
181
211
  end
212
+
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]
222
+ end
182
223
  end
183
224
 
184
225
  module ClassMethods
@@ -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
- model.state_machine_column_mappings = opts[:column_mappings]
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
- dbt = model.db_schema[msgcol][:db_type]
28
- msgarray = dbt.include?("json") || dbt.include?("[]")
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
@@ -52,8 +65,8 @@ module Sequel
52
65
 
53
66
  module InstanceMethods
54
67
  def failed?
55
- from_state = self._get_mapped_column_value(:from_state)
56
- to_state = self._get_mapped_column_value(:to_state)
68
+ from_state = self.sequel_state_machine_get(:from_state)
69
+ to_state = self.sequel_state_machine_get(:to_state)
57
70
  return from_state == to_state
58
71
  end
59
72
 
@@ -62,12 +75,25 @@ module Sequel
62
75
  end
63
76
 
64
77
  def full_message
65
- msg = self._get_mapped_column_value(:messages)
78
+ msg = self.sequel_state_machine_get(:messages)
66
79
  return self.class.state_machine_messages_supports_array ? msg.join(", ") : msg
67
80
  end
68
81
 
69
- def _get_mapped_column_value(col)
70
- return self[self.class.state_machine_column_mappings[col]]
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}" }
71
97
  end
72
98
  end
73
99
  end
@@ -2,48 +2,60 @@
2
2
 
3
3
  require "rspec"
4
4
 
5
- RSpec::Matchers.define :transition_on do |event|
6
- match do |receiver|
7
- raise 'must provide a "to" state' if (@to || "").to_s.empty?
8
- receiver.send(event, *@args)
9
- @to == receiver.send(:state_machine_status)
10
- end
5
+ module StateMachines
6
+ module Sequel
7
+ module SpecHelpers
8
+ module_function def find_state_machine(receiver, event)
9
+ state_machine = receiver.class.state_machines.values.find { |sm| sm.events[event] }
10
+ raise ArgumentError, "receiver #{receiver.class} has no state machine for event #{event}" unless state_machine
11
+ return state_machine
12
+ end
11
13
 
12
- chain :to do |to_state|
13
- @to = to_state
14
- end
14
+ RSpec::Matchers.define :transition_on do |event|
15
+ match do |receiver|
16
+ raise ArgumentError, "must use :to to provide a target state" if (@to || "").to_s.empty?
17
+ machine = StateMachines::Sequel::SpecHelpers.find_state_machine(receiver, event)
18
+ receiver.send(event, *@args)
19
+ current_status = receiver.send(machine.attribute)
20
+ @to == current_status
21
+ end
15
22
 
16
- chain :with do |*args|
17
- @args = args
18
- end
23
+ chain :to do |to_state|
24
+ @to = to_state
25
+ end
19
26
 
20
- chain :audit do
21
- @audit = true
22
- end
27
+ chain :with do |*args|
28
+ @args = args
29
+ end
30
+
31
+ chain :audit do
32
+ @audit = true
33
+ end
23
34
 
24
- failure_message do |receiver|
25
- msg =
26
- if @to == receiver.state_machine_status
27
- "expected that event #{event} would transition, but did not"
28
- else
29
- "expected that event #{event} would transition to #{@to} but is #{receiver.state_machine_status}"
35
+ failure_message do |receiver|
36
+ status = receiver.send(StateMachines::Sequel::SpecHelpers.find_state_machine(receiver, event).attribute)
37
+ msg =
38
+ "expected that event #{event} would transition to #{@to} but is #{status}"
39
+ (msg += "\n#{receiver.audit_logs.map(&:inspect).join("\n")}") if @audit
40
+ msg
41
+ end
30
42
  end
31
- (msg += "\n#{receiver.audit_logs.map(&:inspect).join("\n")}") if @audit
32
- msg
33
- end
34
- end
35
43
 
36
- RSpec::Matchers.define :not_transition_on do |event|
37
- match do |receiver|
38
- !receiver.send(event, *@args)
39
- end
44
+ RSpec::Matchers.define :not_transition_on do |event|
45
+ match do |receiver|
46
+ !receiver.send(event, *@args)
47
+ end
40
48
 
41
- chain :with do |*args|
42
- @args = args
43
- end
49
+ chain :with do |*args|
50
+ @args = args
51
+ end
44
52
 
45
- failure_message do |receiver|
46
- "expected that event #{event} would not transition, but did and is now #{receiver.state_machine_status}"
53
+ failure_message do |receiver|
54
+ status = receiver.send(StateMachines::Sequel::SpecHelpers.find_state_machine(receiver, event).attribute)
55
+ "expected that event #{event} would not transition, but did and is now #{status}"
56
+ end
57
+ end
58
+ end
47
59
  end
48
60
  end
49
61
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StateMachines
4
4
  module Sequel
5
- VERSION = "1.1.0"
5
+ VERSION = "1.2.2"
6
6
  end
7
7
  end
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.1.0
4
+ version: 1.2.2
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-04-05 00:00:00.000000000 Z
11
+ date: 2022-06-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec