sequel-state-machine 1.0.1 → 1.2.1

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: 1eb11cc183e1cfcfc7917f505b02b0f801888492fcf73ac6538e5915972cc799
4
- data.tar.gz: 355f305b70481b88cd306aed45e4bd6ec8b8706ecd921f17fb18e872e85ea36c
3
+ metadata.gz: 7ecbd578f38b3444f3e5174d6bee1fbceb3421eec1d2435a1d3435ec20904ed0
4
+ data.tar.gz: c336baa29efda7c18885264aae288e6fc15f975da155e87be320976be7ccb93d
5
5
  SHA512:
6
- metadata.gz: e26ed08fbc26ece33859b8b68b1ce95573060437054ed1c2e7f69e751c80c5a9d0e29c0ad81ab2c2ec1985c54bcc0fd8327d940329868e7c0937b1baa0f4f64f
7
- data.tar.gz: 32164513eb750973c18e54b73defeb9aa3e45635653725733ff4c1f0f1665693a2eecb569b2d749d04010c586c86bf44f20b8558e6c44f5df85ce9c73b47c8de
6
+ metadata.gz: e9718320cbc2646fd5e3dfdae80e98fda4c6ccd230643ac3301893cee210206400199359297c3e6cac5707b590cdcc065c225d93980ebce4cf3f5ae1b5b493fc
7
+ data.tar.gz: 666800f88b63defc4a2f336315c147eef1338f37574eacdcb2130c930784cbd63ba66cc6cd10df7ff2bbc3260d06315f3bc4686e855100ce577eb21ed2c4154a
@@ -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
- audit_log_assoc = self.class.association_reflections[:audit_logs]
13
- model = Kernel.const_get(audit_log_assoc[:class_name])
14
- 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
15
64
  end
16
65
 
17
- def current_audit_log
18
- 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?
19
70
  StateMachines::Sequel.log(self, :debug, "preparing_audit_log", {})
20
- @current_audit_log = self.new_audit_log
21
- @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, "")
22
82
  end
23
- return @current_audit_log
83
+ return alog
24
84
  end
25
85
 
26
86
  def commit_audit_log(transition)
27
- StateMachines::Sequel.log(self, :debug, "committing_audit_log", {transition: transition})
28
- 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)
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
- @current_audit_log = nil
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.set(
136
+ mapped_values = audlog.sequel_state_machine_map_columns(
73
137
  at: Time.now,
74
138
  event: event,
75
- from_state: self.status,
76
- to_state: self.status,
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
- 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 }
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
- current_state = self.send(self._state_value_attr).to_sym
118
- event_obj = self.class.state_machine.events[event] or raise "Invalid event #{event}"
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
- return true if state_req[:from]&.matches?(current_state)
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 _state_value_attr
128
- return @_state_value_attr ||= self.class.state_machine.attribute
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 validates_state_machine
132
- states = self.class.state_machine.states.map(&:value)
133
- state = self[self._state_value_attr]
134
- return if states.include?(state)
135
- self.errors.add(self._state_value_attr, "status '#{state}' must be one of (#{states.sort.join(', ')})")
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
- 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
@@ -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,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,52 +2,65 @@
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.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.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.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.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
 
50
62
  RSpec.shared_examples "a state machine with audit logging" do |event, to_state|
63
+ let(:machine) { raise NotImplementedError, "must override let(:machine)" }
51
64
  it "logs transitions" do
52
65
  expect(machine).to transition_on(event).to(to_state)
53
66
  expect(machine.audit_logs).to contain_exactly(
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StateMachines
4
4
  module Sequel
5
- VERSION = "1.0.1"
5
+ VERSION = "1.2.1"
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.0.1
4
+ version: 1.2.1
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-03-23 00:00:00.000000000 Z
11
+ date: 2022-04-06 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.