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 +7 -0
- data/lib/sequel/plugins/state_machine.rb +167 -0
- data/lib/sequel/plugins/state_machine_audit_log.rb +66 -0
- data/lib/state_machines/sequel/spec_helpers.rb +57 -0
- data/lib/state_machines/sequel/version.rb +7 -0
- data/lib/state_machines/sequel.rb +64 -0
- data/lib/state_machines.rb +4 -0
- metadata +164 -0
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,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
|
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: []
|