sequel-state-machine 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []