after_migrate 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a04f2af70c48d8299c3c1eedce77608a6a57aca044711bbc2c0e8030299d18e3
4
- data.tar.gz: d511331553aacbe753d40b2004af8d523b2c823ef4ba425f8491eb8a0336d234
3
+ metadata.gz: a13db746f7bfcbcb7367f028493efd2b464e564b32f09be5debb62da1326f8bf
4
+ data.tar.gz: b1f0db683bb9b12ed407f14f3f1b1c0baba0a80cfd445c6182b9bd5ad7378821
5
5
  SHA512:
6
- metadata.gz: c3f07535162f76e33ff8a7a399bbdee3b45d534b48a5609c600504a1f8b0e9c8d4d2e027ec653b7106c12e6663f91194370a9617912bd35860cdb77e6c451e6d
7
- data.tar.gz: 8c529f7d643720957caf72ae2607055e286dbcafc7c15004a03e3908a6be9eab15c7dcc9c66b83c1b741265c70a2a1476e440846178bea2799a4884857639187
6
+ metadata.gz: '085b3be36d4031981294d5a5ddc929aae3d781c9d1c3d8446f4508f7c7c526064c8b6529b68237e3e3517885f22251b3db071839dae1bcdf8df654d1e79b2e70'
7
+ data.tar.gz: 212614cb1145bbe3f22d9b005e6524af133b5d50f659ca8da2efff0d6416efd7ff90fccf39575719d4237df08029a5dcab6b8ef8dfd5072027bce902bc2b3bd8
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.7
1
+ 3.4.9
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.0] - 2026-04-28
6
+
7
+ ### Added
8
+ - `defer` configuration option (default: `true`) — when enabled, rake task enhancements only collect touched tables; they do not run maintenance automatically
9
+ - `AfterMigrate.run!` public API to explicitly trigger maintenance on all collected tables across all schemas, then clear the store — intended to be called once after all tenant migrations complete in multi-tenant setups
10
+ - `after_migrate:run` rake task as a convenience wrapper around `AfterMigrate.run!`
11
+
12
+ ### Changed
13
+ - **Breaking**: replaced `AfterMigrate::Current` (`ActiveSupport::CurrentAttributes`) with a module-level persistent store (`Concurrent::Map`). The store accumulates touched tables across multiple rake task invocations and only resets when `AfterMigrate.run!` is called.
14
+ - Fixed `all_tables` analyze mode - previously iterated `.each_value` (yielding `Concurrent::Set` objects) instead of schema names, producing incorrect SQL queries.
15
+
16
+ ### Removed
17
+ - `AfterMigrate::Current` - no longer needed. If you referenced this class directly, use `AfterMigrate.affected_tables` instead.
18
+ - `Executor.call(reset:)` parameter - the store is always cleared after `run!`; pass `schema:` to scope execution to a single schema.
19
+
5
20
  ## [0.1.0] - 2025-04-05
6
21
 
7
22
  ### Added
data/README.md CHANGED
@@ -28,7 +28,6 @@ Stale statistics and fragmentation after schema changes silently hurt query perf
28
28
  - No monkey-patching of ActiveRecord core classes
29
29
  - Works in development, test, CI, and production
30
30
  - Rails 7.0+ / Ruby 3.2+ only
31
- - Dependency-free
32
31
 
33
32
  ---
34
33
 
@@ -43,5 +43,6 @@ Gem::Specification.new do |spec|
43
43
 
44
44
  spec.required_ruby_version = '>= 3.2'
45
45
 
46
+ spec.add_dependency 'pg_query', '>= 6.1'
46
47
  spec.add_dependency 'rails', '>= 7.0'
47
48
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module AfterMigrate
4
4
  module Mysql
5
+ extend Sql
6
+
5
7
  module_function
6
8
 
7
9
  def optimize_tables(connection:, table_names:, **)
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pg_query'
4
+
3
5
  module AfterMigrate
4
6
  module Postgresql
7
+ extend Sql
8
+
5
9
  module_function
6
10
 
7
11
  def vacuum(table_name, schema: nil, verbose: true)
@@ -64,22 +68,26 @@ module AfterMigrate
64
68
  tables = table_names - cleaned_tables
65
69
  run_analyze(schema:, tables:)
66
70
  end
67
- end
68
71
 
69
- def all_tables(schema: nil)
70
- connection = ActiveRecord::Base.connection
71
- schema_value = schema ? schema.to_s.strip : 'public'
72
+ def all_tables(schema: nil)
73
+ connection = ActiveRecord::Base.connection
74
+ schema_value = schema ? schema.to_s.strip : 'public'
72
75
 
73
- query = <<~SQL.squish
74
- SELECT c.relname AS table_name
75
- FROM pg_class c
76
- JOIN pg_namespace n ON n.oid = c.relnamespace
77
- WHERE n.nspname = #{connection.quote(schema_value)}
78
- AND c.relkind IN ('r', 'p') -- ordinary tables + partitioned tables
79
- AND c.relispartition = FALSE -- exclude partition child tables
80
- ORDER BY table_name
81
- SQL
76
+ query = <<~SQL.squish
77
+ SELECT c.relname AS table_name
78
+ FROM pg_class c
79
+ JOIN pg_namespace n ON n.oid = c.relnamespace
80
+ WHERE n.nspname = #{connection.quote(schema_value)}
81
+ AND c.relkind IN ('r', 'p') -- ordinary tables + partitioned tables
82
+ AND c.relispartition = FALSE -- exclude partition child tables
83
+ ORDER BY table_name
84
+ SQL
85
+
86
+ connection.select_values(query)
87
+ end
82
88
 
83
- connection.select_values(query)
89
+ def parse_tables(sql)
90
+ PgQuery.parse(sql).tables
91
+ end
84
92
  end
85
93
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AfterMigrate
4
+ module Sql
5
+ IDENT = /
6
+ (?:"[\w]+"|\w+)
7
+ (?:\.(?:"[\w]+"|\w+))*
8
+ /x
9
+
10
+ PATTERNS = {
11
+ update: /update\s+(?:only\s+)?(#{IDENT})(?!\()/ix,
12
+ insert: /insert\s+into\s+(#{IDENT})(?!\()/ix,
13
+ delete: /delete\s+from\s+(#{IDENT})(?!\()/ix,
14
+ drop_table: /drop\s+table\s+(?:if\s+exists\s+)?(#{IDENT})(?!\()/ix,
15
+ alter_table: /alter\s+table\s+(#{IDENT})(?!\()/ix,
16
+ create_table: /create\s+table\s+(?:if\s+not\s+exists\s+)?(#{IDENT})(?!\()/ix,
17
+ from_join: /(?:from|join)\s+(#{IDENT})(?!\()/ix
18
+ }.freeze
19
+
20
+ def parse_tables(sql)
21
+ PATTERNS.flat_map { |_, r| sql.scan(r).flatten }.uniq
22
+ end
23
+ end
24
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module AfterMigrate
4
4
  module Sqlite
5
+ extend Sql
6
+
5
7
  module_function
6
8
 
7
9
  def optimize_tables(connection:, **)
@@ -12,29 +12,34 @@ module AfterMigrate
12
12
  return unless sql
13
13
  return unless sql.match?(/\A\s*(CREATE|ALTER|DROP|INSERT|UPDATE|DELETE|RENAME\s+TABLE|TRUNCATE)/i)
14
14
 
15
- table_names = sql.scan(/(?:from|join|update|into|table)\s+((?:"\w+"|\w+)(?:\.(?:"\w+"|\w+))*)/i).flatten
16
- schema ||= fetch_schema
17
- # AfterMigrate.log("[#{schema}] Detected change from '#{sql}' to table: #{table_names}") if table_names.present?
18
- collect_tables(schema:, table_names:)
15
+ table_names = parse_tables(sql)
16
+ schema = fetch_schema
17
+ AfterMigrate.merge_tables(schema, table_names)
19
18
  end
20
19
 
21
20
  private
22
21
 
23
- def collect_tables(schema:, table_names:)
24
- return if table_names.blank?
25
-
26
- AfterMigrate::Current.affected_tables ||= Hash.new { |h, k| h[k] = Concurrent::Set.new }
27
- AfterMigrate::Current.affected_tables[schema].merge(table_names)
28
- end
29
-
30
22
  def fetch_schema
31
23
  connection = ActiveRecord::Base.connection
32
- adapter = connection.adapter_name
33
- case adapter
24
+ case connection.adapter_name
34
25
  when 'PostgreSQL'
35
26
  quoted = connection.schema_search_path.split(',').first
36
27
  quoted&.delete('"')
37
28
  end
38
29
  end
30
+
31
+ def parse_tables(sql)
32
+ connection = ActiveRecord::Base.connection
33
+ case connection.adapter_name
34
+ when 'PostgreSQL'
35
+ AfterMigrate::Postgresql.parse_tables(sql)
36
+ when 'SQLite'
37
+ AfterMigrate::Sqlite.parse_tables(sql)
38
+ when 'Mysql2', 'Trilogy'
39
+ AfterMigrate::Mysql.parse_tables(sql)
40
+ else
41
+ AfterMigrate.log("No maintenance implemented for #{connection.adapter_name}")
42
+ end
43
+ end
39
44
  end
40
45
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'after_migrate/adapters/sql'
3
4
  require 'after_migrate/adapters/mysql'
4
5
  require 'after_migrate/adapters/postgresql'
5
6
  require 'after_migrate/adapters/sqlite'
@@ -10,16 +11,17 @@ module AfterMigrate
10
11
 
11
12
  module_function
12
13
 
13
- def call(reset: true, schema: nil)
14
- # AfterMigrate.log("Executing schema: #{schema} -> #{target_tables}...")
15
- return if target_tables.blank?
16
- return run_optimize(schema:, tables: target_tables[schema]) if schema.present?
14
+ def call(schema: nil)
15
+ tables = target_tables
16
+ return if tables.blank?
17
17
 
18
- target_tables.each do |s, tables|
19
- run_optimize(schema: s, tables:)
18
+ if schema.present?
19
+ run_optimize(schema:, tables: tables[schema]) if tables[schema].present?
20
+ else
21
+ tables.each { |s, t| run_optimize(schema: s, tables: t) }
20
22
  end
21
23
  ensure
22
- AfterMigrate::Current.reset if reset
24
+ AfterMigrate.reset!
23
25
  end
24
26
 
25
27
  public :call
@@ -30,14 +32,13 @@ module AfterMigrate
30
32
  table_names = tables.to_a.sort
31
33
  return if table_names.empty?
32
34
 
33
- AfterMigrate.log("Migration touched #{table_names.size} table(s): #{table_names.join(', ')}")
35
+ AfterMigrate.log("Migration touched #{table_names.size} table(s) in schema #{schema.inspect}: #{table_names.join(', ')}")
34
36
  optimize_tables(schema:, table_names:)
35
37
  end
36
38
 
37
39
  def optimize_tables(schema:, table_names:)
38
40
  connection = ActiveRecord::Base.connection
39
- adapter = connection.adapter_name
40
- case adapter
41
+ case connection.adapter_name
41
42
  when 'PostgreSQL'
42
43
  AfterMigrate::Postgresql.optimize_tables(schema:, table_names:, connection:)
43
44
  when 'SQLite'
@@ -45,27 +46,26 @@ module AfterMigrate
45
46
  when 'Mysql2', 'Trilogy'
46
47
  AfterMigrate::Mysql.optimize_tables(schema:, table_names:, connection:)
47
48
  else
48
- AfterMigrate.log("No maintenance implemented for #{adapter}")
49
+ AfterMigrate.log("No maintenance implemented for #{connection.adapter_name}")
49
50
  end
50
51
  end
51
52
 
52
53
  def target_tables
53
54
  case AfterMigrate.configuration.analyze
54
55
  when 'all_tables'
55
- AfterMigrate::Current.affected_tables.each_value do |schema|
56
- all_tables(schema:)
56
+ AfterMigrate.affected_tables.keys.each_with_object({}) do |schema, hash|
57
+ hash[schema] = all_tables(schema:)
57
58
  end
58
59
  when 'only_affected_tables'
59
- AfterMigrate::Current.affected_tables
60
+ AfterMigrate.affected_tables
60
61
  else
61
- []
62
+ {}
62
63
  end
63
64
  end
64
65
 
65
66
  def all_tables(schema:)
66
67
  connection = ActiveRecord::Base.connection
67
- adapter = connection.adapter_name
68
- case adapter
68
+ case connection.adapter_name
69
69
  when 'PostgreSQL'
70
70
  AfterMigrate::Postgresql.all_tables(schema:)
71
71
  when 'SQLite'
@@ -12,6 +12,8 @@ module AfterMigrate
12
12
  AfterMigrate::Collector.call(*args)
13
13
  end
14
14
 
15
+ # Unsubscribe when the app starts serving requests so normal web traffic
16
+ # is never collected.
15
17
  app.executor.to_run { ActiveSupport::Notifications.unsubscribe(subscription) }
16
18
  end
17
19
 
@@ -24,8 +26,16 @@ module AfterMigrate
24
26
  Rake::Task[task_name].enhance do
25
27
  next unless AfterMigrate.configuration.enabled
26
28
  next unless AfterMigrate.configuration.rake_tasks_enhanced
29
+ next if AfterMigrate.configuration.defer
27
30
 
28
- AfterMigrate::Executor.call(reset: true)
31
+ AfterMigrate.run!
32
+ end
33
+ end
34
+
35
+ namespace :after_migrate do
36
+ desc 'Run database maintenance (ANALYZE/VACUUM) on all tables collected across migrations'
37
+ task run: :environment do
38
+ AfterMigrate.run!
29
39
  end
30
40
  end
31
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AfterMigrate
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/after_migrate.rb CHANGED
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'after_migrate/version'
4
- require 'after_migrate/current'
5
4
  require 'after_migrate/collector'
6
5
  require 'after_migrate/executor'
7
6
  require 'after_migrate/railtie'
8
7
 
9
8
  module AfterMigrate
10
9
  class Configuration
11
- attr_accessor :enabled, :verbose, :vacuum, :analyze, :rake_tasks_enhanced
10
+ attr_accessor :enabled, :verbose, :vacuum, :analyze, :rake_tasks_enhanced, :defer
12
11
 
13
12
  def initialize
14
13
  @enabled = true
@@ -16,6 +15,7 @@ module AfterMigrate
16
15
  @vacuum = true
17
16
  @analyze = 'only_affected_tables'
18
17
  @rake_tasks_enhanced = true
18
+ @defer = true
19
19
  end
20
20
  end
21
21
 
@@ -31,5 +31,29 @@ module AfterMigrate
31
31
  def log(msg)
32
32
  warn "[after_migrate] #{msg}" if AfterMigrate.configuration.verbose
33
33
  end
34
+
35
+ # Persistent cross-migration store: schema_name => Concurrent::Set<table_name>
36
+ def affected_tables
37
+ @affected_tables ||= Concurrent::Map.new
38
+ end
39
+
40
+ def merge_tables(schema, table_names)
41
+ return if table_names.blank?
42
+
43
+ set = affected_tables.compute_if_absent(schema) { Concurrent::Set.new }
44
+ set.merge(table_names)
45
+ end
46
+
47
+ # Trigger database maintenance on all collected tables, then clear the store.
48
+ # In multi-tenant setups call this once after all tenant migrations complete.
49
+ def run!(schema: nil)
50
+ return unless configuration.enabled
51
+
52
+ Executor.call(schema:)
53
+ end
54
+
55
+ def reset!
56
+ @affected_tables = nil
57
+ end
34
58
  end
35
59
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: after_migrate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikolay Moskvin
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pg_query
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: rails
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -48,9 +62,9 @@ files:
48
62
  - lib/after_migrate.rb
49
63
  - lib/after_migrate/adapters/mysql.rb
50
64
  - lib/after_migrate/adapters/postgresql.rb
65
+ - lib/after_migrate/adapters/sql.rb
51
66
  - lib/after_migrate/adapters/sqlite.rb
52
67
  - lib/after_migrate/collector.rb
53
- - lib/after_migrate/current.rb
54
68
  - lib/after_migrate/executor.rb
55
69
  - lib/after_migrate/railtie.rb
56
70
  - lib/after_migrate/version.rb
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_support/current_attributes'
4
-
5
- module AfterMigrate
6
- class Current < ActiveSupport::CurrentAttributes
7
- attribute :affected_tables
8
-
9
- resets do
10
- self.affected_tables = Hash.new { |h, k| h[k] = Concurrent::Set.new }
11
- end
12
- end
13
- end