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: 75e3b94c74a1a1c835374f9e6be8d51c84b62d24cc7fd205f00ee325e1dde15a
4
- data.tar.gz: 44fcd2ffd47a5ade52a4c9eda0f6bc86f8b5991f8d9e7cbf7cf9fe0194ba8aa1
3
+ metadata.gz: 3f0c0bd5c716d726e9e8ba679d545038637a3ed7da8a569918e24725d0558021
4
+ data.tar.gz: dcba20e8cb5eb3fbcbad3e70e5e5d0662d3515c44ebb6fa033efc5ae4f1e7a02
5
5
  SHA512:
6
- metadata.gz: 38a3c446b7b00f3a80b1257a37a0705712bc93e397963378709b0bc289aa8e85d372d869e53c94ae439a1ab83c3b507cb07af0ebe5049abd89e9c8c76088e382
7
- data.tar.gz: 7a7da89ec5c83001e726c90dd83e52b973754528a71d9a360c78ea2a1bb9a09ae9dc47c965e5962d156c38fd85a052c0ac56d7f29607e8689564f8cd11b0aec0
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
- 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,13 +65,35 @@ module Sequel
52
65
 
53
66
  module InstanceMethods
54
67
  def failed?
55
- colmap = self.state_machine_column_mappings
56
- return colmap[:from_state] == colmap[:to_state]
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
- colmap = self.state_machine_column_mappings
61
- return colmap[:from_state] != colmap[:to_state]
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 'must provide a "to" state' if (@to || "").to_s.empty?
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.status
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.status
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.status}"
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.status}"
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(
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StateMachines
4
4
  module Sequel
5
- VERSION = "1.0.0"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
@@ -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.present?
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.messages.last}"
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.0.0
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-03-06 00:00:00.000000000 Z
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.