sequel-state-machine 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 75e3b94c74a1a1c835374f9e6be8d51c84b62d24cc7fd205f00ee325e1dde15a
4
+ data.tar.gz: 44fcd2ffd47a5ade52a4c9eda0f6bc86f8b5991f8d9e7cbf7cf9fe0194ba8aa1
5
+ SHA512:
6
+ metadata.gz: 38a3c446b7b00f3a80b1257a37a0705712bc93e397963378709b0bc289aa8e85d372d869e53c94ae439a1ab83c3b507cb07af0ebe5049abd89e9c8c76088e382
7
+ data.tar.gz: 7a7da89ec5c83001e726c90dd83e52b973754528a71d9a360c78ea2a1bb9a09ae9dc47c965e5962d156c38fd85a052c0ac56d7f29607e8689564f8cd11b0aec0
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "sequel/model"
5
+ require "state_machines/sequel"
6
+
7
+ module Sequel
8
+ module Plugins
9
+ module StateMachine
10
+ module InstanceMethods
11
+ 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
15
+ end
16
+
17
+ def current_audit_log
18
+ if @current_audit_log.nil?
19
+ StateMachines::Sequel.log(self, :debug, "preparing_audit_log", {})
20
+ @current_audit_log = self.new_audit_log
21
+ @current_audit_log.reason = ""
22
+ end
23
+ return @current_audit_log
24
+ end
25
+
26
+ def commit_audit_log(transition)
27
+ StateMachines::Sequel.log(self, :debug, "committing_audit_log", {transition: transition})
28
+ current = self.current_audit_log
29
+
30
+ 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
34
+ end
35
+ if last_saved
36
+ StateMachines::Sequel.log(self, :debug, "updating_audit_log", {audit_log_id: last_saved.id})
37
+ last_saved.update(
38
+ at: Time.now,
39
+ actor: StateMachines::Sequel.current_actor,
40
+ messages: current.messages,
41
+ reason: current.reason,
42
+ )
43
+ else
44
+ StateMachines::Sequel.log(self, :debug, "creating_audit_log", {})
45
+ current.set(
46
+ at: Time.now,
47
+ actor: StateMachines::Sequel.current_actor,
48
+ event: transition.event.to_s,
49
+ from_state: transition.from,
50
+ to_state: transition.to,
51
+ )
52
+ self.add_audit_log(current)
53
+ end
54
+ @current_audit_log = nil
55
+ end
56
+
57
+ def audit(message, reason: nil)
58
+ audlog = self.current_audit_log
59
+ if audlog.class.state_machine_messages_supports_array
60
+ audlog.messages ||= []
61
+ audlog.messages << message
62
+ else
63
+ audlog.messages ||= ""
64
+ audlog.messages += (audlog.messages.empty? ? message : (message + "\n"))
65
+ end
66
+ audlog.reason = reason if reason
67
+ end
68
+
69
+ def audit_one_off(event, messages, reason: nil)
70
+ messages = [messages] unless messages.respond_to?(:to_ary)
71
+ audlog = self.new_audit_log
72
+ audlog.set(
73
+ at: Time.now,
74
+ event: event,
75
+ from_state: self.status,
76
+ to_state: self.status,
77
+ messages: audlog.class.state_machine_messages_supports_array ? messages : messages.join("\n"),
78
+ reason: reason || "",
79
+ actor: StateMachines::Sequel.current_actor,
80
+ )
81
+ self.add_audit_log(audlog)
82
+ end
83
+
84
+ # Send event with arguments inside of a transaction, save the changes to the receiver,
85
+ # and return the transition result.
86
+ # Used to ensure the event processing happens in a transaction and the receiver is saved.
87
+ def process(event, *args)
88
+ self.db.transaction do
89
+ self.lock!
90
+ result = self.send(event, *args)
91
+ self.save_changes
92
+ return result
93
+ end
94
+ end
95
+
96
+ # Same as process, but raises an error if the transition fails.
97
+ def must_process(event, *args)
98
+ success = self.process(event, *args)
99
+ raise StateMachines::Sequel::FailedTransition.new(self, event) unless success
100
+ return self
101
+ end
102
+
103
+ # Same as must_process, but takes a lock,
104
+ # and calls the given block, only doing actual processing if the block returns true.
105
+ # If the block returns false, it acts as a success.
106
+ # Used to avoid issues concurrently processing the same object through the same state.
107
+ def process_if(event, *args)
108
+ self.db.transaction do
109
+ self.lock!
110
+ return self unless yield(self)
111
+ return self.must_process(event, *args)
112
+ end
113
+ end
114
+
115
+ # 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}"
119
+ event_obj.branches.each do |branch|
120
+ branch.state_requirements.each do |state_req|
121
+ return true if state_req[:from]&.matches?(current_state)
122
+ end
123
+ end
124
+ return false
125
+ end
126
+
127
+ def _state_value_attr
128
+ return @_state_value_attr ||= self.class.state_machine.attribute
129
+ end
130
+
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(', ')})")
136
+ end
137
+ end
138
+
139
+ module ClassMethods
140
+ def timestamp_accessors(events_and_accessors)
141
+ events_and_accessors.each do |(ev, acc)|
142
+ self.timestamp_accessor(ev, acc)
143
+ end
144
+ end
145
+
146
+ # Register the timestamp access for an event.
147
+ # A timestamp accessor reads when a certain transition happened
148
+ # by looking at the timestamp of the successful transition into that state.
149
+ #
150
+ # The event can be just the event name, or a hash of {event: <event method symbol>, from: <state name>},
151
+ # used when a single event can cause multiple transitions.
152
+ def timestamp_accessor(event, accessor)
153
+ define_method(accessor) do
154
+ event = {event: event} if event.is_a?(String)
155
+ audit = self.audit_logs.select(&:succeeded?).find do |a|
156
+ ev_match = event[:event].nil? || event[:event] == a.event
157
+ from_match = event[:from].nil? || event[:from] == a.from_state
158
+ to_match = event[:to].nil? || event[:to] == a.to_state
159
+ ev_match && from_match && to_match
160
+ end
161
+ return audit&.at
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "sequel/model"
5
+
6
+ module Sequel
7
+ module Plugins
8
+ module StateMachineAuditLog
9
+ DEFAULT_OPTIONS = {
10
+ 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,
20
+ }.freeze
21
+ def self.configure(model, opts=DEFAULT_OPTIONS)
22
+ opts = DEFAULT_OPTIONS.merge(opts)
23
+ model.state_machine_column_mappings = opts[:column_mappings]
24
+ msgarray = opts[:messages_supports_array] || true
25
+ if msgarray == :undefined
26
+ msgcol = model.state_machine_column_mappings[:messages]
27
+ dbt = model.db_schema[msgcol][:db_type]
28
+ msgarray = dbt.include?("json") || dbt.include?("[]")
29
+ end
30
+ model.state_machine_messages_supports_array = msgarray
31
+ end
32
+
33
+ module ClassMethods
34
+ attr_accessor :state_machine_messages_supports_array, :state_machine_column_mappings
35
+ end
36
+
37
+ module DatasetMethods
38
+ def failed
39
+ colmap = self.state_machine_column_mappings
40
+ tostate_col = colmap[:to_state]
41
+ fromstate_col = colmap[:from_state]
42
+ return self.where(tostate_col => fromstate_col)
43
+ end
44
+
45
+ def succeeded
46
+ colmap = self.state_machine_column_mappings
47
+ tostate_col = colmap[:to_state]
48
+ fromstate_col = colmap[:from_state]
49
+ return self.exclude(tostate_col => fromstate_col)
50
+ end
51
+ end
52
+
53
+ module InstanceMethods
54
+ def failed?
55
+ colmap = self.state_machine_column_mappings
56
+ return colmap[:from_state] == colmap[:to_state]
57
+ end
58
+
59
+ def succeeded?
60
+ colmap = self.state_machine_column_mappings
61
+ return colmap[:from_state] != colmap[:to_state]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
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
11
+
12
+ chain :to do |to_state|
13
+ @to = to_state
14
+ end
15
+
16
+ chain :with do |*args|
17
+ @args = args
18
+ end
19
+
20
+ chain :audit do
21
+ @audit = true
22
+ end
23
+
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}"
30
+ end
31
+ (msg += "\n#{receiver.audit_logs.map(&:inspect).join("\n")}") if @audit
32
+ msg
33
+ end
34
+ end
35
+
36
+ RSpec::Matchers.define :not_transition_on do |event|
37
+ match do |receiver|
38
+ !receiver.send(event, *@args)
39
+ end
40
+
41
+ chain :with do |*args|
42
+ @args = args
43
+ end
44
+
45
+ failure_message do |receiver|
46
+ "expected that event #{event} would not transition, but did and is now #{receiver.status}"
47
+ end
48
+ end
49
+
50
+ RSpec.shared_examples "a state machine with audit logging" do |event, to_state|
51
+ it "logs transitions" do
52
+ expect(machine).to transition_on(event).to(to_state)
53
+ expect(machine.audit_logs).to contain_exactly(
54
+ have_attributes(to_state: to_state, event: event.to_s),
55
+ )
56
+ end
57
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module Sequel
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module Sequel
5
+ class CurrentActorAlreadySet < StateMachines::Error; end
6
+
7
+ class FailedTransition < StateMachines::Error
8
+ attr_reader :event, :object, :audit_log
9
+
10
+ def initialize(obj, event)
11
+ @event = event
12
+ @object = obj
13
+ msg = "#{obj.class}[#{obj.id}] failed to transition on #{event}"
14
+ if obj.respond_to?(:audit_logs) && obj.audit_logs.present?
15
+ @audit_log = obj.audit_logs.max_by(&:id)
16
+ msg += ": #{@audit_log.messages.last}"
17
+ end
18
+ super(msg)
19
+ end
20
+ end
21
+
22
+ class << self
23
+ attr_accessor :structured_logging
24
+
25
+ # Proc called with [instance, level, message, params].
26
+ # By default, logs to `instance.logger` if it instance responds to :logger.
27
+ # If structured_logging is true, the message will be an 'event' without any dynamic info,
28
+ # if false, the params will be rendered into the message so are suitable for unstructured logging.
29
+ attr_accessor :log_callback
30
+
31
+ def reset_logging
32
+ self.log_callback = lambda { |instance, level, msg, _params|
33
+ instance.respond_to?(:logger) ? instance.logger.send(level, msg) : nil
34
+ }
35
+ self.structured_logging = false
36
+ end
37
+
38
+ def log(instance, level, message, params)
39
+ if self.structured_logging
40
+ paramstr = params.map { |k, v| "#{k}=#{v}" }.join(" ")
41
+ message = "#{message} #{paramstr}"
42
+ end
43
+ self.log_callback[instance, level, message, params]
44
+ end
45
+
46
+ def current_actor
47
+ return Thread.current[:sequel_state_machines_current_actor]
48
+ end
49
+
50
+ def set_current_actor(admin, &block)
51
+ raise CurrentActorAlreadySet, "already set to: #{self.current_actor}" if !admin.nil? && !self.current_actor.nil?
52
+ Thread.current[:sequel_state_machines_current_actor] = admin
53
+ return if block.nil?
54
+ begin
55
+ yield
56
+ ensure
57
+ Thread.current[:sequel_state_machines_current_actor] = nil
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ StateMachines::Sequel.reset_logging
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "state_machines"
4
+ require "state_machines/sequel"
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sequel-state-machine
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Lithic Tech
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-03-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.10'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.10'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-core
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.10'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.11'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.11'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop-performance
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.10'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.10'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-sequel
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.2'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sequel
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '5.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '5.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: sqlite3
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1'
111
+ - !ruby/object:Gem::Dependency
112
+ name: state_machines
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: |
126
+ sequel-state-machine hooks together the excellent Ruby Sequel ORM to
127
+ the state-machines library, with auditing and other tools.
128
+ email: hello@lithic.tech
129
+ executables: []
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - lib/sequel/plugins/state_machine.rb
134
+ - lib/sequel/plugins/state_machine_audit_log.rb
135
+ - lib/state_machines.rb
136
+ - lib/state_machines/sequel.rb
137
+ - lib/state_machines/sequel/spec_helpers.rb
138
+ - lib/state_machines/sequel/version.rb
139
+ homepage: https://github.com/lithictech/sequel-state-machine
140
+ licenses:
141
+ - MIT
142
+ metadata:
143
+ rubygems_mfa_required: 'true'
144
+ post_install_message:
145
+ rdoc_options: []
146
+ require_paths:
147
+ - lib
148
+ required_ruby_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: 2.7.0
153
+ required_rubygems_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ requirements: []
159
+ rubygems_version: 3.1.6
160
+ signing_key:
161
+ specification_version: 4
162
+ summary: Hook together the excellent Ruby Sequel ORM to the state-machines library,
163
+ with auditing and other tools.
164
+ test_files: []