statesman 10.2.3 → 12.0.0

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