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: 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.