statesman 10.2.3 → 12.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/tests.yml +12 -10
  3. data/.gitignore +0 -3
  4. data/.rspec +1 -0
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG.md +15 -0
  7. data/Gemfile +2 -2
  8. data/lib/generators/statesman/generator_helpers.rb +1 -1
  9. data/lib/statesman/adapters/active_record.rb +26 -33
  10. data/lib/statesman/adapters/active_record_queries.rb +1 -1
  11. data/lib/statesman/adapters/active_record_transition.rb +5 -1
  12. data/lib/statesman/config.rb +3 -10
  13. data/lib/statesman/version.rb +1 -1
  14. data/lib/statesman.rb +2 -4
  15. data/lib/tasks/statesman.rake +2 -2
  16. data/spec/generators/statesman/active_record_transition_generator_spec.rb +7 -1
  17. data/spec/generators/statesman/migration_generator_spec.rb +5 -1
  18. data/spec/spec_helper.rb +34 -8
  19. data/spec/statesman/adapters/active_record_queries_spec.rb +1 -3
  20. data/spec/statesman/adapters/active_record_spec.rb +58 -39
  21. data/spec/statesman/adapters/active_record_transition_spec.rb +5 -2
  22. data/spec/statesman/adapters/memory_spec.rb +0 -1
  23. data/spec/statesman/adapters/memory_transition_spec.rb +0 -1
  24. data/spec/statesman/adapters/shared_examples.rb +0 -2
  25. data/spec/statesman/adapters/type_safe_active_record_queries_spec.rb +1 -3
  26. data/spec/statesman/callback_spec.rb +0 -2
  27. data/spec/statesman/config_spec.rb +0 -2
  28. data/spec/statesman/exceptions_spec.rb +1 -3
  29. data/spec/statesman/guard_spec.rb +0 -2
  30. data/spec/statesman/machine_spec.rb +0 -2
  31. data/spec/statesman/utils_spec.rb +0 -2
  32. data/spec/support/active_record.rb +55 -13
  33. data/spec/support/exactly_query_databases.rb +35 -0
  34. data/statesman.gemspec +2 -2
  35. metadata +9 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cc749949e382ced6f27722a0eb033efc0383ba11a06265a84e1da3b593eb02d
4
- data.tar.gz: f2dd851b5c9ccb0aacbde8076c10fcf23bfe8a00926e58acfb9dd714c5d61b99
3
+ metadata.gz: a799d928f8e674203dde325b7653ed8fe2b719f5b9108ded9353afaa5eefe028
4
+ data.tar.gz: a92e8f0a4a49a22e66c4d3a83ccdec62e042cf8cd2a734368090f9144373f02a
5
5
  SHA512:
6
- metadata.gz: 4ac72394722dc0d193874967e79bb30df1a1c7ba63e4eccd20294f2298b2e78637b3c7ffff7fe4ef4f4ad5d3ca522efe89444400fa09231684622848754a7683
7
- data.tar.gz: e995d62de4ec4471ec1a66fea6e290bfbc5f0a3420c3bd915117a0e92f08ca20994b59e84ed5f73a93d091156ea47ef35548ea61f50ad52a1942a69438d4f446
6
+ metadata.gz: 781f2603034ba02007a26678f06738ff5b0691cf2bd45c9d56406c3d4bffe15f375301b52f42f4a5e5bf8fd7fcb2b10b61e61d623cfab5d21c901025cb6f70a6
7
+ data.tar.gz: df7bfefdb22eaf086e25dfc91b233bd3eb02b3c7e658315e05b4d1e92a30e534f626622b7025a8eff244f6b29c1637bb3ed0678a1c04cab0c5ffd180b18df105
@@ -24,15 +24,16 @@ jobs:
24
24
  strategy:
25
25
  fail-fast: false
26
26
  matrix:
27
- ruby-version: ["2.7", "3.0", "3.1", "3.2"]
27
+ ruby-version: ["3.0", "3.1", "3.2"]
28
28
  rails-version:
29
- - "6.1.5"
30
- - "7.0.4"
29
+ - "6.1.7.6"
30
+ - "7.0.8"
31
+ - "7.1.1"
31
32
  - "main"
32
- postgres-version: ["9.6", "11", "14"]
33
+ postgres-version: ["12", "13", "14", "15", "16"]
33
34
  exclude:
34
35
  - ruby-version: "3.2"
35
- rails-version: "6.1.5"
36
+ rails-version: "6.1.7.6"
36
37
  runs-on: ubuntu-latest
37
38
  services:
38
39
  postgres:
@@ -66,15 +67,16 @@ jobs:
66
67
  strategy:
67
68
  fail-fast: false
68
69
  matrix:
69
- ruby-version: ["2.7", "3.0", "3.1", "3.2"]
70
+ ruby-version: ["3.0", "3.1", "3.2"]
70
71
  rails-version:
71
- - "6.1.5"
72
- - "7.0.4"
72
+ - "6.1.7.6"
73
+ - "7.0.8"
74
+ - "7.1.1"
73
75
  - "main"
74
- mysql-version: ["5.7", "8.0"]
76
+ mysql-version: ["8.0", "8.2"]
75
77
  exclude:
76
78
  - ruby-version: 3.2
77
- rails-version: "6.1.5"
79
+ rails-version: "6.1.7.6"
78
80
  runs-on: ubuntu-latest
79
81
  services:
80
82
  mysql:
data/.gitignore CHANGED
@@ -55,9 +55,6 @@ Gemfile.lock
55
55
  # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
56
  # .rubocop-https?--*
57
57
 
58
- # Project-specific ignores
59
- .rspec
60
-
61
58
  # VSCode
62
59
  .vscode
63
60
 
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.2.0
1
+ 3.2.2
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## v12.0.0 30th November 2023
2
+
3
+ ### Changed
4
+ - Added multi-database support [#522](https://github.com/gocardless/statesman/pull/522)
5
+ - This now uses the correct ActiveRecord connection for the model or transition in a multi-database environment
6
+
7
+ ## v11.0.0 3rd November 2023
8
+
9
+ ### Changed
10
+ - Updated to support ActiveRecord > 7.2
11
+ - Remove support for:
12
+ - Ruby; 2.7
13
+ - Postgres; 9.6, 10, 11
14
+ - MySQL; 5.7
15
+
1
16
  ## v10.2.3 2nd Aug 2023
2
17
 
3
18
  ### Changed
data/Gemfile CHANGED
@@ -9,8 +9,8 @@ if ENV['RAILS_VERSION'] == 'main'
9
9
  elsif ENV['RAILS_VERSION']
10
10
  gem "rails", "~> #{ENV['RAILS_VERSION']}"
11
11
  end
12
+
12
13
  group :development do
13
- # test/unit is no longer bundled with Ruby 2.2, but required by Rails
14
14
  gem "pry"
15
- gem "test-unit", "~> 3.3" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.2.0")
15
+ gem "test-unit", "~> 3.3"
16
16
  end
@@ -52,7 +52,7 @@ module Statesman
52
52
  end
53
53
 
54
54
  def database_supports_partial_indexes?
55
- Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
55
+ Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(klass.constantize)
56
56
  end
57
57
 
58
58
  def metadata_default_value
@@ -7,19 +7,15 @@ module Statesman
7
7
  class ActiveRecord
8
8
  JSON_COLUMN_TYPES = %w[json jsonb].freeze
9
9
 
10
- def self.database_supports_partial_indexes?
10
+ def self.database_supports_partial_indexes?(model)
11
11
  # Rails 3 doesn't implement `supports_partial_index?`
12
- if ::ActiveRecord::Base.connection.respond_to?(:supports_partial_index?)
13
- ::ActiveRecord::Base.connection.supports_partial_index?
12
+ if model.connection.respond_to?(:supports_partial_index?)
13
+ model.connection.supports_partial_index?
14
14
  else
15
- ::ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
15
+ model.connection.adapter_name.casecmp("postgresql").zero?
16
16
  end
17
17
  end
18
18
 
19
- def self.adapter_name
20
- ::ActiveRecord::Base.connection.adapter_name.downcase
21
- end
22
-
23
19
  def initialize(transition_class, parent_model, observer, options = {})
24
20
  serialized = serialized?(transition_class)
25
21
  column_type = transition_class.columns_hash["metadata"].sql_type
@@ -88,10 +84,10 @@ module Statesman
88
84
  default_transition_attributes(to, metadata),
89
85
  )
90
86
 
91
- ::ActiveRecord::Base.transaction(requires_new: true) do
87
+ transition_class.transaction(requires_new: true) do
92
88
  @observer.execute(:before, from, to, transition)
93
89
 
94
- if mysql_gaplock_protection?
90
+ if mysql_gaplock_protection?(transition_class.connection)
95
91
  # We save the transition first with most_recent falsy, then mark most_recent
96
92
  # true after to avoid letting MySQL acquire a next-key lock which can cause
97
93
  # deadlocks.
@@ -130,8 +126,8 @@ module Statesman
130
126
  end
131
127
 
132
128
  def add_after_commit_callback(from, to, transition)
133
- ::ActiveRecord::Base.connection.add_transaction_record(
134
- ActiveRecordAfterCommitWrap.new do
129
+ transition_class.connection.add_transaction_record(
130
+ ActiveRecordAfterCommitWrap.new(transition_class.connection) do
135
131
  @observer.execute(:after_commit, from, to, transition)
136
132
  end,
137
133
  )
@@ -144,7 +140,7 @@ module Statesman
144
140
  # Sets the given transition most_recent = t while unsetting the most_recent of any
145
141
  # previous transitions.
146
142
  def update_most_recents(most_recent_id = nil)
147
- update = build_arel_manager(::Arel::UpdateManager)
143
+ update = build_arel_manager(::Arel::UpdateManager, transition_class)
148
144
  update.table(transition_table)
149
145
  update.where(most_recent_transitions(most_recent_id))
150
146
  update.set(build_most_recents_update_all_values(most_recent_id))
@@ -152,9 +148,11 @@ module Statesman
152
148
  # MySQL will validate index constraints across the intermediate result of an
153
149
  # update. This means we must order our update to deactivate the previous
154
150
  # most_recent before setting the new row to be true.
155
- update.order(transition_table[:most_recent].desc) if mysql_gaplock_protection?
151
+ if mysql_gaplock_protection?(transition_class.connection)
152
+ update.order(transition_table[:most_recent].desc)
153
+ end
156
154
 
157
- ::ActiveRecord::Base.connection.update(update.to_sql)
155
+ transition_class.connection.update(update.to_sql(transition_class))
158
156
  end
159
157
 
160
158
  def most_recent_transitions(most_recent_id = nil)
@@ -223,7 +221,7 @@ module Statesman
223
221
  if most_recent_id
224
222
  Arel::Nodes::Case.new.
225
223
  when(transition_table[:id].eq(most_recent_id)).then(db_true).
226
- else(not_most_recent_value).to_sql
224
+ else(not_most_recent_value).to_sql(transition_class)
227
225
  else
228
226
  Arel::Nodes::SqlLiteral.new(not_most_recent_value)
229
227
  end
@@ -233,11 +231,11 @@ module Statesman
233
231
  # change in Arel as we move into Rails >6.0.
234
232
  #
235
233
  # https://github.com/rails/rails/commit/7508284800f67b4611c767bff9eae7045674b66f
236
- def build_arel_manager(manager)
234
+ def build_arel_manager(manager, engine)
237
235
  if manager.instance_method(:initialize).arity.zero?
238
236
  manager.new
239
237
  else
240
- manager.new(::ActiveRecord::Base)
238
+ manager.new(engine)
241
239
  end
242
240
  end
243
241
 
@@ -246,13 +244,8 @@ module Statesman
246
244
  end
247
245
 
248
246
  def serialized?(transition_class)
249
- if ::ActiveRecord.respond_to?(:gem_version) &&
250
- ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
251
- transition_class.type_for_attribute("metadata").
252
- is_a?(::ActiveRecord::Type::Serialized)
253
- else
254
- transition_class.serialized_attributes.include?("metadata")
255
- end
247
+ transition_class.type_for_attribute("metadata").
248
+ is_a?(::ActiveRecord::Type::Serialized)
256
249
  end
257
250
 
258
251
  def transition_conflict_error?(err)
@@ -263,7 +256,7 @@ module Statesman
263
256
  end
264
257
 
265
258
  def unique_indexes
266
- ::ActiveRecord::Base.connection.
259
+ transition_class.connection.
267
260
  indexes(transition_class.table_name).
268
261
  select do |index|
269
262
  next unless index.unique
@@ -334,16 +327,16 @@ module Statesman
334
327
  ::ActiveRecord::Base.default_timezone
335
328
  end
336
329
 
337
- def mysql_gaplock_protection?
338
- Statesman.mysql_gaplock_protection?
330
+ def mysql_gaplock_protection?(connection)
331
+ Statesman.mysql_gaplock_protection?(connection)
339
332
  end
340
333
 
341
334
  def db_true
342
- ::ActiveRecord::Base.connection.quote(type_cast(true))
335
+ transition_class.connection.quote(type_cast(true))
343
336
  end
344
337
 
345
338
  def db_false
346
- ::ActiveRecord::Base.connection.quote(type_cast(false))
339
+ transition_class.connection.quote(type_cast(false))
347
340
  end
348
341
 
349
342
  def db_null
@@ -353,7 +346,7 @@ module Statesman
353
346
  # Type casting against a column is deprecated and will be removed in Rails 6.2.
354
347
  # See https://github.com/rails/arel/commit/6160bfbda1d1781c3b08a33ec4955f170e95be11
355
348
  def type_cast(value)
356
- ::ActiveRecord::Base.connection.type_cast(value)
349
+ transition_class.connection.type_cast(value)
357
350
  end
358
351
 
359
352
  # Check whether the `most_recent` column allows null values. If it doesn't, set old
@@ -373,9 +366,9 @@ module Statesman
373
366
  end
374
367
 
375
368
  class ActiveRecordAfterCommitWrap
376
- def initialize(&block)
369
+ def initialize(connection, &block)
377
370
  @callback = block
378
- @connection = ::ActiveRecord::Base.connection
371
+ @connection = connection
379
372
  end
380
373
 
381
374
  def self.trigger_transactional_callbacks?
@@ -153,7 +153,7 @@ module Statesman
153
153
  end
154
154
 
155
155
  def db_true
156
- ::ActiveRecord::Base.connection.quote(true)
156
+ model.connection.quote(true)
157
157
  end
158
158
  end
159
159
  end
@@ -10,7 +10,11 @@ module Statesman
10
10
  extend ActiveSupport::Concern
11
11
 
12
12
  included do
13
- serialize :metadata, JSON
13
+ if ::ActiveRecord.gem_version >= Gem::Version.new("7.1")
14
+ serialize :metadata, coder: JSON
15
+ else
16
+ serialize :metadata, JSON
17
+ end
14
18
 
15
19
  class_attribute :updated_timestamp_column
16
20
  self.updated_timestamp_column = DEFAULT_UPDATED_TIMESTAMP_COLUMN
@@ -15,17 +15,10 @@ module Statesman
15
15
  @adapter_class = adapter_class
16
16
  end
17
17
 
18
- def mysql_gaplock_protection?
19
- return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
20
-
18
+ def mysql_gaplock_protection?(connection)
21
19
  # If our adapter class suggests we're using mysql, enable gaplock protection by
22
20
  # default.
23
- enable_mysql_gaplock_protection if mysql_adapter?(adapter_class)
24
- @mysql_gaplock_protection
25
- end
26
-
27
- def enable_mysql_gaplock_protection
28
- @mysql_gaplock_protection = true
21
+ mysql_adapter?(connection)
29
22
  end
30
23
 
31
24
  private
@@ -34,7 +27,7 @@ module Statesman
34
27
  adapter_name = adapter_name(adapter_class)
35
28
  return false unless adapter_name
36
29
 
37
- adapter_name.start_with?("mysql")
30
+ adapter_name.downcase.start_with?("mysql")
38
31
  end
39
32
 
40
33
  def adapter_name(adapter_class)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statesman
4
- VERSION = "10.2.3"
4
+ VERSION = "12.0.0"
5
5
  end
data/lib/statesman.rb CHANGED
@@ -34,10 +34,8 @@ module Statesman
34
34
  @storage_adapter || Adapters::Memory
35
35
  end
36
36
 
37
- def self.mysql_gaplock_protection?
38
- return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
39
-
40
- @mysql_gaplock_protection = config.mysql_gaplock_protection?
37
+ def self.mysql_gaplock_protection?(connection)
38
+ config.mysql_gaplock_protection?(connection)
41
39
  end
42
40
 
43
41
  def self.config
@@ -21,8 +21,8 @@ namespace :statesman do
21
21
  batch_size = 500
22
22
 
23
23
  parent_class.find_in_batches(batch_size: batch_size) do |models|
24
- ActiveRecord::Base.transaction(requires_new: true) do
25
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
24
+ transition_class.transaction(requires_new: true) do
25
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(transition_class)
26
26
  # Set all transitions' most_recent to FALSE
27
27
  transition_class.where(parent_fk => models.map(&:id)).
28
28
  update_all(most_recent: false, updated_at: updated_at)
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "support/generators_shared_examples"
5
4
  require "generators/statesman/active_record_transition_generator"
6
5
 
7
6
  describe Statesman::ActiveRecordTransitionGenerator, type: :generator do
7
+ before do
8
+ stub_const("Bacon", Class.new(ActiveRecord::Base))
9
+ stub_const("BaconTransition", Class.new(ActiveRecord::Base))
10
+ stub_const("Yummy::Bacon", Class.new(ActiveRecord::Base))
11
+ stub_const("Yummy::BaconTransition", Class.new(ActiveRecord::Base))
12
+ end
13
+
8
14
  it_behaves_like "a generator" do
9
15
  let(:migration_name) { "db/migrate/create_bacon_transitions.rb" }
10
16
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "support/generators_shared_examples"
5
4
  require "generators/statesman/migration_generator"
6
5
 
7
6
  describe Statesman::MigrationGenerator, type: :generator do
7
+ before do
8
+ stub_const("Yummy::Bacon", Class.new(ActiveRecord::Base))
9
+ stub_const("Yummy::BaconTransition", Class.new(ActiveRecord::Base))
10
+ end
11
+
8
12
  it_behaves_like "a generator" do
9
13
  let(:migration_name) { "db/migrate/add_statesman_to_bacon_transitions.rb" }
10
14
  end
data/spec/spec_helper.rb CHANGED
@@ -5,13 +5,14 @@ require "sqlite3"
5
5
  require "mysql2"
6
6
  require "pg"
7
7
  require "active_record"
8
+ require "active_record/database_configurations"
8
9
  # We have to include all of Rails to make rspec-rails work
9
10
  require "rails"
10
11
  require "action_view"
11
12
  require "action_dispatch"
12
13
  require "action_controller"
13
14
  require "rspec/rails"
14
- require "support/active_record"
15
+ require "support/exactly_query_databases"
15
16
  require "rspec/its"
16
17
  require "pry"
17
18
 
@@ -28,10 +29,31 @@ RSpec.configure do |config|
28
29
  if config.exclusion_filter[:active_record]
29
30
  puts "Skipping ActiveRecord tests"
30
31
  else
31
- # Connect to the database for activerecord tests
32
- db_conn_spec = ENV["DATABASE_URL"]
33
- db_conn_spec ||= { adapter: "sqlite3", database: ":memory:" }
34
- ActiveRecord::Base.establish_connection(db_conn_spec)
32
+ current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
33
+
34
+ # We have to parse this to a hash since ActiveRecord::Base.configurations
35
+ # will only consider a single URL config.
36
+ url_config = if ENV["DATABASE_URL"]
37
+ ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.
38
+ new(ENV["DATABASE_URL"]).to_hash.merge({ sslmode: "disable" })
39
+ end
40
+
41
+ db_config = {
42
+ current_env => {
43
+ primary: url_config || {
44
+ adapter: "sqlite3",
45
+ database: "/tmp/statesman.db",
46
+ },
47
+ secondary: url_config || {
48
+ adapter: "sqlite3",
49
+ database: "/tmp/statesman.db",
50
+ },
51
+ },
52
+ }
53
+
54
+ # Connect to the primary database for activerecord tests.
55
+ ActiveRecord::Base.configurations = db_config
56
+ ActiveRecord::Base.establish_connection(:primary)
35
57
 
36
58
  db_adapter = ActiveRecord::Base.connection.adapter_name
37
59
  puts "Running with database adapter '#{db_adapter}'"
@@ -40,7 +62,9 @@ RSpec.configure do |config|
40
62
  ActiveRecord::Migration.verbose = false
41
63
  end
42
64
 
43
- config.before(:each, active_record: true) do
65
+ # Since our primary and secondary connections point to the same database, we don't
66
+ # need to worry about applying these actions to both.
67
+ config.before(:each, :active_record) do
44
68
  tables = %w[
45
69
  my_active_record_models
46
70
  my_active_record_model_transitions
@@ -53,6 +77,7 @@ RSpec.configure do |config|
53
77
  ]
54
78
  tables.each do |table_name|
55
79
  sql = "DROP TABLE IF EXISTS #{table_name};"
80
+
56
81
  ActiveRecord::Base.connection.execute(sql)
57
82
  end
58
83
 
@@ -82,7 +107,8 @@ RSpec.configure do |config|
82
107
  CreateStiActiveRecordModelTransitionMigration.migrate(:up)
83
108
  StiActiveRecordModelTransition.reset_column_information
84
109
  end
85
-
86
- MyNamespace::MyActiveRecordModelTransition.serialize(:metadata, JSON)
87
110
  end
88
111
  end
112
+
113
+ # We have to require this after the databases are configured.
114
+ require "support/active_record"
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
- describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
3
+ describe Statesman::Adapters::ActiveRecordQueries, :active_record do
6
4
  def configure_old(klass, transition_class)
7
5
  klass.define_singleton_method(:transition_class) { transition_class }
8
6
  klass.define_singleton_method(:initial_state) { :initial }
@@ -1,17 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "timecop"
5
4
  require "statesman/adapters/shared_examples"
6
5
  require "statesman/exceptions"
7
6
 
8
- describe Statesman::Adapters::ActiveRecord, active_record: true do
7
+ describe Statesman::Adapters::ActiveRecord, :active_record do
9
8
  before do
10
9
  prepare_model_table
11
10
  prepare_transitions_table
12
11
 
13
- MyActiveRecordModelTransition.serialize(:metadata, JSON)
14
-
15
12
  prepare_sti_model_table
16
13
  prepare_sti_transitions_table
17
14
 
@@ -26,8 +23,10 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
26
23
 
27
24
  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
28
25
 
26
+ let(:model_class) { MyActiveRecordModel }
27
+ let(:transition_class) { MyActiveRecordModelTransition }
29
28
  let(:observer) { double(Statesman::Machine, execute: nil) }
30
- let(:model) { MyActiveRecordModel.create(current_state: :pending) }
29
+ let(:model) { model_class.create(current_state: :pending) }
31
30
 
32
31
  it_behaves_like "an adapter", described_class, MyActiveRecordModelTransition
33
32
 
@@ -36,17 +35,11 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
36
35
  before do
37
36
  metadata_column = double
38
37
  allow(metadata_column).to receive_messages(sql_type: "")
39
- allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
40
- { "metadata" => metadata_column })
41
- if ActiveRecord.respond_to?(:gem_version) &&
42
- ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
43
- expect(MyActiveRecordModelTransition).
44
- to receive(:type_for_attribute).with("metadata").
45
- and_return(ActiveRecord::Type::Value.new)
46
- else
47
- expect(MyActiveRecordModelTransition).
48
- to receive_messages(serialized_attributes: {})
49
- end
38
+ allow(MyActiveRecordModelTransition).
39
+ to receive_messages(columns_hash: { "metadata" => metadata_column })
40
+ expect(MyActiveRecordModelTransition).
41
+ to receive(:type_for_attribute).with("metadata").
42
+ and_return(ActiveRecord::Type::Value.new)
50
43
  end
51
44
 
52
45
  it "raises an exception" do
@@ -91,18 +84,12 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
91
84
  allow(metadata_column).to receive_messages(sql_type: "jsonb")
92
85
  allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
93
86
  { "metadata" => metadata_column })
94
- if ActiveRecord.respond_to?(:gem_version) &&
95
- ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
96
- serialized_type = ActiveRecord::Type::Serialized.new(
97
- "", ActiveRecord::Coders::JSON
98
- )
99
- expect(MyActiveRecordModelTransition).
100
- to receive(:type_for_attribute).with("metadata").
101
- and_return(serialized_type)
102
- else
103
- expect(MyActiveRecordModelTransition).
104
- to receive_messages(serialized_attributes: { "metadata" => "" })
105
- end
87
+ serialized_type = ActiveRecord::Type::Serialized.new(
88
+ "", ActiveRecord::Coders::JSON
89
+ )
90
+ expect(MyActiveRecordModelTransition).
91
+ to receive(:type_for_attribute).with("metadata").
92
+ and_return(serialized_type)
106
93
  end
107
94
 
108
95
  it "raises an exception" do
@@ -117,13 +104,15 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
117
104
  describe "#create" do
118
105
  subject(:transition) { create }
119
106
 
120
- let!(:adapter) do
121
- described_class.new(MyActiveRecordModelTransition, model, observer)
122
- end
107
+ let!(:adapter) { described_class.new(transition_class, model, observer) }
123
108
  let(:from) { :x }
124
109
  let(:to) { :y }
125
110
  let(:create) { adapter.create(from, to) }
126
111
 
112
+ it "only connects to the primary database" do
113
+ expect { create }.to exactly_query_databases({ primary: [:writing] })
114
+ end
115
+
127
116
  context "when there is a race" do
128
117
  it "raises a TransitionConflictError" do
129
118
  adapter2 = adapter.dup
@@ -131,7 +120,8 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
131
120
  adapter.last
132
121
  adapter2.create(:y, :z)
133
122
  expect { adapter.create(:y, :z) }.
134
- to raise_exception(Statesman::TransitionConflictError)
123
+ to raise_exception(Statesman::TransitionConflictError).
124
+ and exactly_query_databases({ primary: [:writing] })
135
125
  end
136
126
 
137
127
  it "does not pollute the state when the transition fails" do
@@ -355,12 +345,34 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
355
345
  end
356
346
  end
357
347
  end
348
+
349
+ context "when using the secondary database" do
350
+ let(:model_class) { SecondaryActiveRecordModel }
351
+ let(:transition_class) { SecondaryActiveRecordModelTransition }
352
+
353
+ it "doesn't connect to the primary database" do
354
+ expect { create }.to exactly_query_databases({ secondary: [:writing] })
355
+ expect(adapter.last.to_state).to eq("y")
356
+ end
357
+
358
+ context "when there is a race" do
359
+ it "raises a TransitionConflictError and uses the correct database" do
360
+ adapter2 = adapter.dup
361
+ adapter2.create(:x, :y)
362
+ adapter.last
363
+ adapter2.create(:y, :z)
364
+
365
+ expect { adapter.create(:y, :z) }.
366
+ to raise_exception(Statesman::TransitionConflictError).
367
+ and exactly_query_databases({ secondary: [:writing] })
368
+ end
369
+ end
370
+ end
358
371
  end
359
372
 
360
373
  describe "#last" do
361
- let(:adapter) do
362
- described_class.new(MyActiveRecordModelTransition, model, observer)
363
- end
374
+ let(:transition_class) { MyActiveRecordModelTransition }
375
+ let(:adapter) { described_class.new(transition_class, model, observer) }
364
376
 
365
377
  context "with a previously looked up transition" do
366
378
  before { adapter.create(:x, :y) }
@@ -377,8 +389,19 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
377
389
  before { adapter.create(:y, :z, []) }
378
390
 
379
391
  it "retrieves the new transition from the database" do
392
+ expect { adapter.last.to_state }.to exactly_query_databases({ primary: [:writing] })
380
393
  expect(adapter.last.to_state).to eq("z")
381
394
  end
395
+
396
+ context "when using the secondary database" do
397
+ let(:model_class) { SecondaryActiveRecordModel }
398
+ let(:transition_class) { SecondaryActiveRecordModelTransition }
399
+
400
+ it "retrieves the new transition from the database" do
401
+ expect { adapter.last.to_state }.to exactly_query_databases({ secondary: [:writing] })
402
+ expect(adapter.last.to_state).to eq("z")
403
+ end
404
+ end
382
405
  end
383
406
 
384
407
  context "when a new transition has been created elsewhere" do
@@ -467,10 +490,6 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
467
490
  CreateNamespacedARModelTransitionMigration.migrate(:up)
468
491
  end
469
492
 
470
- before do
471
- MyNamespace::MyActiveRecordModelTransition.serialize(:metadata, JSON)
472
- end
473
-
474
493
  let(:observer) { double(Statesman::Machine, execute: nil) }
475
494
  let(:model) do
476
495
  MyNamespace::MyActiveRecordModel.create(current_state: :pending)
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "json"
5
4
 
6
5
  describe Statesman::Adapters::ActiveRecordTransition do
@@ -8,7 +7,11 @@ describe Statesman::Adapters::ActiveRecordTransition do
8
7
 
9
8
  describe "including behaviour" do
10
9
  it "calls Class.serialize" do
11
- expect(transition_class).to receive(:serialize).with(:metadata, JSON).once
10
+ if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.1")
11
+ expect(transition_class).to receive(:serialize).with(:metadata, coder: JSON).once
12
+ else
13
+ expect(transition_class).to receive(:serialize).with(:metadata, JSON).once
14
+ end
12
15
  transition_class.send(:include, described_class)
13
16
  end
14
17
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "statesman/adapters/shared_examples"
5
4
  require "statesman/adapters/memory_transition"
6
5
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "statesman/adapters/memory_transition"
5
4
 
6
5
  describe Statesman::Adapters::MemoryTransition do
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  # All adpators must define seven methods:
6
4
  # initialize: Accepts a transition class, parent model and state_attr.
7
5
  # transition_class: Returns the transition class object passed to initialize.
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
- describe Statesman::Adapters::TypeSafeActiveRecordQueries, active_record: true do
3
+ describe Statesman::Adapters::TypeSafeActiveRecordQueries, :active_record do
6
4
  def configure(klass, transition_class)
7
5
  klass.send(:extend, described_class)
8
6
  klass.configure_state_machine(
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Callback do
6
4
  let(:cb_lambda) { -> {} }
7
5
  let(:callback) do
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Config do
6
4
  let(:instance) { described_class.new }
7
5
 
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
- describe Statesman do
3
+ describe "Exceptions" do
6
4
  describe "InvalidStateError" do
7
5
  subject(:error) { Statesman::InvalidStateError.new }
8
6
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Guard do
6
4
  let(:callback) { -> {} }
7
5
  let(:guard) { described_class.new(from: nil, to: nil, callback: callback) }
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Machine do
6
4
  let(:machine) { Class.new { include Statesman::Machine } }
7
5
  let(:my_model) { Class.new { attr_accessor :current_state }.new }
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Utils do
6
4
  describe ".rails_major_version" do
7
5
  subject { described_class.rails_major_version }
@@ -24,7 +24,6 @@ class MyActiveRecordModelTransition < ActiveRecord::Base
24
24
  include Statesman::Adapters::ActiveRecordTransition
25
25
 
26
26
  belongs_to :my_active_record_model
27
- serialize :metadata, JSON
28
27
  end
29
28
 
30
29
  class MyActiveRecordModel < ActiveRecord::Base
@@ -51,7 +50,11 @@ class MyActiveRecordModelTransitionWithoutInclude < ActiveRecord::Base
51
50
  self.table_name = "my_active_record_model_transitions"
52
51
 
53
52
  belongs_to :my_active_record_model
54
- serialize :metadata, JSON
53
+ if ::ActiveRecord.gem_version >= Gem::Version.new("7.1")
54
+ serialize :metadata, coder: JSON
55
+ else
56
+ serialize :metadata, JSON
57
+ end
55
58
  end
56
59
 
57
60
  class CreateMyActiveRecordModelMigration < MIGRATION_CLASS
@@ -78,7 +81,7 @@ class CreateMyActiveRecordModelTransitionMigration < MIGRATION_CLASS
78
81
  t.text :metadata, default: "{}"
79
82
  end
80
83
 
81
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
84
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
82
85
  t.boolean :most_recent, default: true, null: false
83
86
  else
84
87
  t.boolean :most_recent, default: true
@@ -95,7 +98,7 @@ class CreateMyActiveRecordModelTransitionMigration < MIGRATION_CLASS
95
98
  %i[my_active_record_model_id sort_key],
96
99
  unique: true, name: "sort_key_index"
97
100
 
98
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
101
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
99
102
  add_index :my_active_record_model_transitions,
100
103
  %i[my_active_record_model_id most_recent],
101
104
  unique: true,
@@ -129,7 +132,48 @@ class OtherActiveRecordModelTransition < ActiveRecord::Base
129
132
  include Statesman::Adapters::ActiveRecordTransition
130
133
 
131
134
  belongs_to :other_active_record_model
132
- serialize :metadata, JSON
135
+ end
136
+
137
+ class SecondaryRecord < ActiveRecord::Base
138
+ self.abstract_class = true
139
+
140
+ connects_to database: { writing: :secondary, reading: :secondary }
141
+ end
142
+
143
+ class SecondaryActiveRecordModelTransition < SecondaryRecord
144
+ self.table_name = "my_active_record_model_transitions"
145
+
146
+ include Statesman::Adapters::ActiveRecordTransition
147
+
148
+ belongs_to :my_active_record_model,
149
+ class_name: "SecondaryActiveRecordModel",
150
+ foreign_key: "my_active_record_model_transition_id"
151
+ end
152
+
153
+ class SecondaryActiveRecordModel < SecondaryRecord
154
+ self.table_name = "my_active_record_models"
155
+
156
+ has_many :my_active_record_model_transitions,
157
+ class_name: "SecondaryActiveRecordModelTransition",
158
+ foreign_key: "my_active_record_model_id",
159
+ autosave: false
160
+
161
+ alias_method :transitions, :my_active_record_model_transitions
162
+
163
+ include Statesman::Adapters::ActiveRecordQueries[
164
+ transition_class: SecondaryActiveRecordModelTransition,
165
+ initial_state: :initial
166
+ ]
167
+
168
+ def state_machine
169
+ @state_machine ||= MyStateMachine.new(
170
+ self, transition_class: SecondaryActiveRecordModelTransition
171
+ )
172
+ end
173
+
174
+ def metadata
175
+ super || {}
176
+ end
133
177
  end
134
178
 
135
179
  class CreateOtherActiveRecordModelMigration < MIGRATION_CLASS
@@ -156,7 +200,7 @@ class CreateOtherActiveRecordModelTransitionMigration < MIGRATION_CLASS
156
200
  t.text :metadata, default: "{}"
157
201
  end
158
202
 
159
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
203
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
160
204
  t.boolean :most_recent, default: true, null: false
161
205
  else
162
206
  t.boolean :most_recent, default: true
@@ -169,7 +213,7 @@ class CreateOtherActiveRecordModelTransitionMigration < MIGRATION_CLASS
169
213
  %i[other_active_record_model_id sort_key],
170
214
  unique: true, name: "other_sort_key_index"
171
215
 
172
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
216
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
173
217
  add_index :other_active_record_model_transitions,
174
218
  %i[other_active_record_model_id most_recent],
175
219
  unique: true,
@@ -221,7 +265,6 @@ module MyNamespace
221
265
 
222
266
  belongs_to :my_active_record_model,
223
267
  class_name: "MyNamespace::MyActiveRecordModel"
224
- serialize :metadata, JSON
225
268
 
226
269
  def self.table_name_prefix
227
270
  "my_namespace_"
@@ -252,7 +295,7 @@ class CreateNamespacedARModelTransitionMigration < MIGRATION_CLASS
252
295
  t.text :metadata, default: "{}"
253
296
  end
254
297
 
255
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
298
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
256
299
  t.boolean :most_recent, default: true, null: false
257
300
  else
258
301
  t.boolean :most_recent, default: true
@@ -264,7 +307,7 @@ class CreateNamespacedARModelTransitionMigration < MIGRATION_CLASS
264
307
  add_index :my_namespace_my_active_record_model_transitions, :sort_key,
265
308
  unique: true, name: "my_namespaced_key"
266
309
 
267
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
310
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
268
311
  add_index :my_namespace_my_active_record_model_transitions,
269
312
  %i[my_active_record_model_id most_recent],
270
313
  unique: true,
@@ -310,7 +353,6 @@ class StiActiveRecordModelTransition < ActiveRecord::Base
310
353
  include Statesman::Adapters::ActiveRecordTransition
311
354
 
312
355
  belongs_to :sti_active_record_model
313
- serialize :metadata, JSON
314
356
  end
315
357
 
316
358
  class StiAActiveRecordModelTransition < StiActiveRecordModelTransition
@@ -342,7 +384,7 @@ class CreateStiActiveRecordModelTransitionMigration < MIGRATION_CLASS
342
384
  t.text :metadata, default: "{}"
343
385
  end
344
386
 
345
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
387
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
346
388
  t.boolean :most_recent, default: true, null: false
347
389
  else
348
390
  t.boolean :most_recent, default: true
@@ -355,7 +397,7 @@ class CreateStiActiveRecordModelTransitionMigration < MIGRATION_CLASS
355
397
  %i[type sti_active_record_model_id sort_key],
356
398
  unique: true, name: "sti_sort_key_index"
357
399
 
358
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
400
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(ActiveRecord::Base)
359
401
  add_index :sti_active_record_model_transitions,
360
402
  %i[type sti_active_record_model_id most_recent],
361
403
  unique: true,
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `expected_dbs` should be a Hash of the form:
4
+ # {
5
+ # primary: [:writing, :reading],
6
+ # replica: [:reading],
7
+ # }
8
+ RSpec::Matchers.define :exactly_query_databases do |expected_dbs|
9
+ match do |block|
10
+ @expected_dbs = expected_dbs.transform_values(&:to_set).with_indifferent_access
11
+ @actual_dbs = Hash.new { |h, k| h[k] = Set.new }.with_indifferent_access
12
+
13
+ ActiveSupport::Notifications.
14
+ subscribe("sql.active_record") do |_name, _start, _finish, _id, payload|
15
+ pool = payload.fetch(:connection).pool
16
+
17
+ next if pool.is_a?(ActiveRecord::ConnectionAdapters::NullPool)
18
+
19
+ name = pool.db_config.name
20
+ role = pool.role
21
+
22
+ @actual_dbs[name] << role
23
+ end
24
+
25
+ block.call
26
+
27
+ @actual_dbs == @expected_dbs
28
+ end
29
+
30
+ failure_message do |_block|
31
+ "expected to query exactly #{@expected_dbs}, but queried #{@actual_dbs}"
32
+ end
33
+
34
+ supports_block_expectations
35
+ end
data/statesman.gemspec CHANGED
@@ -24,9 +24,9 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.add_development_dependency "ammeter", "~> 1.1"
26
26
  spec.add_development_dependency "bundler", "~> 2"
27
- spec.add_development_dependency "gc_ruboconfig", "~> 3.6.0"
27
+ spec.add_development_dependency "gc_ruboconfig", "~> 4.3.0"
28
28
  spec.add_development_dependency "mysql2", ">= 0.4", "< 0.6"
29
- spec.add_development_dependency "pg", ">= 0.18", "<= 1.5"
29
+ spec.add_development_dependency "pg", ">= 0.18", "<= 1.6"
30
30
  spec.add_development_dependency "rails", ">= 5.2"
31
31
  spec.add_development_dependency "rake", "~> 13.0.0"
32
32
  spec.add_development_dependency "rspec", "~> 3.1"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statesman
3
3
  version: !ruby/object:Gem::Version
4
- version: 10.2.3
4
+ version: 12.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-04 00:00:00.000000000 Z
11
+ date: 2023-11-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ammeter
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 3.6.0
47
+ version: 4.3.0
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 3.6.0
54
+ version: 4.3.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: mysql2
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -81,7 +81,7 @@ dependencies:
81
81
  version: '0.18'
82
82
  - - "<="
83
83
  - !ruby/object:Gem::Version
84
- version: '1.5'
84
+ version: '1.6'
85
85
  type: :development
86
86
  prerelease: false
87
87
  version_requirements: !ruby/object:Gem::Requirement
@@ -91,7 +91,7 @@ dependencies:
91
91
  version: '0.18'
92
92
  - - "<="
93
93
  - !ruby/object:Gem::Version
94
- version: '1.5'
94
+ version: '1.6'
95
95
  - !ruby/object:Gem::Dependency
96
96
  name: rails
97
97
  requirement: !ruby/object:Gem::Requirement
@@ -214,6 +214,7 @@ files:
214
214
  - ".github/dependabot.yml"
215
215
  - ".github/workflows/tests.yml"
216
216
  - ".gitignore"
217
+ - ".rspec"
217
218
  - ".rubocop.yml"
218
219
  - ".rubocop_todo.yml"
219
220
  - ".ruby-version"
@@ -267,6 +268,7 @@ files:
267
268
  - spec/statesman/machine_spec.rb
268
269
  - spec/statesman/utils_spec.rb
269
270
  - spec/support/active_record.rb
271
+ - spec/support/exactly_query_databases.rb
270
272
  - spec/support/generators_shared_examples.rb
271
273
  - statesman.gemspec
272
274
  homepage: https://github.com/gocardless/statesman
@@ -294,7 +296,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
294
296
  - !ruby/object:Gem::Version
295
297
  version: '0'
296
298
  requirements: []
297
- rubygems_version: 3.4.1
299
+ rubygems_version: 3.4.10
298
300
  signing_key:
299
301
  specification_version: 4
300
302
  summary: A statesman-like state machine library