after_migrate 0.2.0 → 0.2.1

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: a13db746f7bfcbcb7367f028493efd2b464e564b32f09be5debb62da1326f8bf
4
- data.tar.gz: b1f0db683bb9b12ed407f14f3f1b1c0baba0a80cfd445c6182b9bd5ad7378821
3
+ metadata.gz: 19aeb8ce5d84227e4ea467f7e0d14ff8ccdb34f49214f9201b7550ede7ffbbf3
4
+ data.tar.gz: 2c52a7d680cb37bc2536be8ccd822fee3b92fc6e2b91a5587fa7da1826d14993
5
5
  SHA512:
6
- metadata.gz: '085b3be36d4031981294d5a5ddc929aae3d781c9d1c3d8446f4508f7c7c526064c8b6529b68237e3e3517885f22251b3db071839dae1bcdf8df654d1e79b2e70'
7
- data.tar.gz: 212614cb1145bbe3f22d9b005e6524af133b5d50f659ca8da2efff0d6416efd7ff90fccf39575719d4237df08029a5dcab6b8ef8dfd5072027bce902bc2b3bd8
6
+ metadata.gz: f8f671d511d7dc8cecc9a33e8bd90752cd0bb5b5ccb50a485dd7215c50ee68d76ec9ce626850d18717bb507a50e0bfe31e71425d24975607144b3749da1d9345
7
+ data.tar.gz: 80e42d103e33f52644366ebd660a0d5252c55934386830c68f33c9418b37ffbb92310c1c2dc2dd033d9a62204f0bd8d8c78f222852796d5838c4526ae313809e
data/.gitignore CHANGED
@@ -9,3 +9,5 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+ .idea/
13
+ /Gemfile.lock
data/.rubocop.yml CHANGED
@@ -1,2 +1,14 @@
1
1
  AllCops:
2
2
  NewCops: enable
3
+ SuggestExtensions: false
4
+
5
+ Style/Documentation:
6
+ Enabled: false
7
+
8
+ Metrics/MethodLength:
9
+ Max: 20
10
+
11
+ Metrics/BlockLength:
12
+ Exclude:
13
+ - 'after_migrate.gemspec'
14
+ - 'spec/**/*.rb'
data/CLAUDE.md ADDED
@@ -0,0 +1,74 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ bundle install # install dependencies
9
+ bundle exec rspec # run all tests
10
+ bundle exec rspec spec/after_migrate_spec.rb # run a single spec file
11
+ bundle exec rake # default task (runs spec)
12
+ bundle exec rubocop # lint
13
+ bundle exec rubocop -a # lint with auto-fix
14
+ rake release # build and push gem to RubyGems
15
+ ```
16
+
17
+ ## Architecture
18
+
19
+ This is a Rails gem that automatically runs database maintenance (`ANALYZE`, `VACUUM`, `PRAGMA optimize`) after `db:migrate` tasks. The core flow is:
20
+
21
+ 1. **`Railtie`** (`lib/after_migrate/railtie.rb`) — the entry point. On Rails init, it subscribes to `sql.active_record` notifications so every SQL statement during a migration is intercepted. It enhances `db:migrate`, `db:migrate:up`, and `db:migrate:redo` to call `AfterMigrate.run!` when they complete — unless `defer: true` (the default), in which case the rake task only collects. It also registers the `after_migrate:run` task.
22
+
23
+ 2. **`Collector`** (`lib/after_migrate/collector.rb`) — receives every SQL notification, filters to DDL/DML statements (CREATE/ALTER/DROP/INSERT/UPDATE/DELETE), calls the adapter-specific parser, and accumulates table names into `AfterMigrate.affected_tables` (a `Concurrent::Map<schema, Concurrent::Set<table_name>>`).
24
+
25
+ 3. **`AfterMigrate.affected_tables`** (module-level store in `lib/after_migrate.rb`) — a `Concurrent::Map` that persists across multiple rake task invocations. Only cleared when `AfterMigrate.run!` (or `AfterMigrate.reset!`) is called. This replaces the old `Current` (`ActiveSupport::CurrentAttributes`) which was reset after every migration.
26
+
27
+ 4. **`Executor`** (`lib/after_migrate/executor.rb`) — iterates `AfterMigrate.affected_tables` and calls the correct adapter's `optimize_tables`. Respects the `analyze` config option (`only_affected_tables` / `all_tables` / `none`). Always calls `AfterMigrate.reset!` in its `ensure` block.
28
+
29
+ 5. **Adapters** (`lib/after_migrate/adapters/`) — one module per database:
30
+ - `Sql` — shared regex-based table parser (used by MySQL and SQLite, which can't use pg_query)
31
+ - `Postgresql` — uses `pg_query` gem for accurate SQL parsing; runs `VACUUM` (checking `pg_stat_all_tables` for dead tuples) then `ANALYZE VERBOSE` per table
32
+ - `Mysql` — runs `ANALYZE TABLE` per table; lists tables from `information_schema`
33
+ - `Sqlite` — runs `PRAGMA optimize` (SQLite ≥ 3.35.0) or `VACUUM; ANALYZE;`
34
+
35
+ ## Key design decisions
36
+
37
+ - PostgreSQL uses `pg_query` (the actual Postgres parser) for table extraction, which avoids false positives from regex. MySQL and SQLite fall back to the shared `Sql` regex patterns.
38
+ - `pg_query` is a hard runtime dependency (listed in gemspec), not optional — even though it's only used for PostgreSQL.
39
+ - Table collection uses `Concurrent::Map` + `Concurrent::Set` (from `concurrent-ruby`) for thread-safe accumulation across parallel migration workers.
40
+ - The gem does **not** monkey-patch ActiveRecord. It only uses public `ActiveSupport::Notifications` and `Rake::Task#enhance` APIs.
41
+ - `defer: true` (the default) is the multi-tenant-friendly mode: rake tasks only collect, never execute. Call `AfterMigrate.run!` (or `rake after_migrate:run`) once after all tenant migrations complete.
42
+ - The `app.executor.to_run` unsubscription in `Railtie` ensures the SQL subscription is dropped before the app starts serving web requests, so normal traffic is never collected.
43
+
44
+ ## Configuration
45
+
46
+ Config values are in `AfterMigrate::Configuration` (initialized in `lib/after_migrate.rb`):
47
+
48
+ | Option | Default | Values |
49
+ |-----------------------|--------------------------|----------------------------------------------------|
50
+ | `enabled` | `true` | bool |
51
+ | `verbose` | `true` | bool |
52
+ | `vacuum` | `true` | bool (PostgreSQL only) |
53
+ | `analyze` | `"only_affected_tables"` | `"only_affected_tables"`, `"all_tables"`, `"none"` |
54
+ | `rake_tasks_enhanced` | `true` | bool |
55
+ | `defer` | `true` | bool — skip auto-run; call `AfterMigrate.run!` manually |
56
+
57
+ ## Multi-tenant usage
58
+
59
+ ```ruby
60
+ # In your tenant migration runner:
61
+ Tenant.each do |tenant|
62
+ tenant.switch { ActiveRecord::MigrationContext.new(...).migrate }
63
+ end
64
+ # Tables are now accumulated per schema in AfterMigrate.affected_tables
65
+ AfterMigrate.run! # or: Rake::Task['after_migrate:run'].invoke
66
+ ```
67
+
68
+ Set `defer: false` to restore the v0.1 behaviour of running after each `db:migrate`.
69
+
70
+ ## Dependencies
71
+
72
+ - Ruby ≥ 3.2, Rails ≥ 7.0
73
+ - `pg_query ≥ 6.1` (required even for non-Postgres installs)
74
+ - Dev: `rspec ~> 3.0`, `rubocop ~> 1.81`, `bundler ~> 4`
data/README.md CHANGED
@@ -83,6 +83,30 @@ end
83
83
  ```
84
84
  ---
85
85
 
86
+ ## 🚢 Releasing
87
+
88
+ Use the guarded helper to make sure git is pushed before RubyGems publish:
89
+
90
+ ```bash
91
+ bin/release
92
+ ```
93
+
94
+ What it does:
95
+ - requires a clean git worktree
96
+ - ensures you are on the default branch (`origin/HEAD` fallback)
97
+ - checks branch sync status vs `origin`
98
+ - runs `bundle exec rspec` and `bundle exec rubocop`
99
+ - pushes the branch first
100
+ - runs `bundle exec rake release` (build/tag/push/publish)
101
+
102
+ Optional:
103
+
104
+ ```bash
105
+ bin/release --skip-checks
106
+ ```
107
+
108
+ ---
109
+
86
110
  ## 🤝 Contributing
87
111
 
88
112
  Bug reports, feature requests, and pull requests are very welcome!
data/bin/release ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ root_dir=$(cd "$(dirname "$0")/.." && pwd)
6
+ cd "$root_dir"
7
+
8
+ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
9
+ cat <<'USAGE'
10
+ Usage: bin/release [--skip-checks]
11
+
12
+ Runs guardrails before releasing:
13
+ 1. Requires a clean git worktree
14
+ 2. Requires current branch to match origin/HEAD (or local HEAD fallback)
15
+ 3. Requires branch to be in sync with origin
16
+ 4. Runs test + lint checks (unless --skip-checks)
17
+ 5. Pushes the branch, then runs `bundle exec rake release`
18
+ USAGE
19
+ exit 0
20
+ fi
21
+
22
+ skip_checks=false
23
+ if [[ "${1:-}" == "--skip-checks" ]]; then
24
+ skip_checks=true
25
+ elif [[ $# -gt 0 ]]; then
26
+ echo "Unknown option: $1" >&2
27
+ exit 1
28
+ fi
29
+
30
+ if [[ -n "$(git status --porcelain)" ]]; then
31
+ echo "Working tree is not clean. Commit or stash changes before releasing." >&2
32
+ exit 1
33
+ fi
34
+
35
+ current_branch=$(git rev-parse --abbrev-ref HEAD)
36
+ default_branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
37
+ default_branch=${default_branch:-$current_branch}
38
+
39
+ if [[ "$current_branch" != "$default_branch" ]]; then
40
+ echo "Release must run from '$default_branch' (current: '$current_branch')." >&2
41
+ exit 1
42
+ fi
43
+
44
+ echo "Fetching latest refs from origin..."
45
+ git fetch origin
46
+
47
+ local_sha=$(git rev-parse @)
48
+ remote_sha=$(git rev-parse "origin/$default_branch")
49
+ base_sha=$(git merge-base @ "origin/$default_branch")
50
+
51
+ if [[ "$local_sha" == "$remote_sha" ]]; then
52
+ echo "Branch is in sync with origin/$default_branch."
53
+ elif [[ "$local_sha" == "$base_sha" ]]; then
54
+ echo "Local branch is behind origin/$default_branch. Pull/rebase first." >&2
55
+ exit 1
56
+ elif [[ "$remote_sha" == "$base_sha" ]]; then
57
+ echo "Local branch is ahead of origin/$default_branch and will be pushed."
58
+ else
59
+ echo "Local and origin/$default_branch have diverged. Rebase/merge first." >&2
60
+ exit 1
61
+ fi
62
+
63
+ version=$(ruby -e 'require_relative "lib/after_migrate/version"; puts AfterMigrate::VERSION')
64
+ tag="v$version"
65
+
66
+ if git rev-parse "$tag" >/dev/null 2>&1 || git ls-remote --tags origin "$tag" | grep -q "$tag"; then
67
+ echo "Tag '$tag' already exists locally or on origin. Bump version first." >&2
68
+ exit 1
69
+ fi
70
+
71
+ if [[ "$skip_checks" == "false" ]]; then
72
+ echo "Running specs..."
73
+ bundle exec rspec
74
+
75
+ echo "Running RuboCop..."
76
+ bundle exec rubocop
77
+ fi
78
+
79
+ echo "Pushing branch '$default_branch' first..."
80
+ git push origin "$default_branch"
81
+
82
+ echo "Releasing gem with rake task..."
83
+ bundle exec rake release
84
+
85
+ echo "Release complete: $tag"
@@ -9,7 +9,8 @@ module AfterMigrate
9
9
  module_function
10
10
 
11
11
  def vacuum(table_name, schema: nil, verbose: true)
12
- table = ActiveRecord::Base.connection.quote_table_name("#{schema}.#{table_name}")
12
+ qualified = schema.present? ? "#{schema}.#{table_name}" : table_name
13
+ table = ActiveRecord::Base.connection.quote_table_name(qualified)
13
14
  query = <<~SQL.squish
14
15
  VACUUM (#{'VERBOSE, ' if verbose}ANALYZE, INDEX_CLEANUP ON) #{table};
15
16
  SQL
@@ -41,10 +42,11 @@ module AfterMigrate
41
42
  ActiveRecord::Base.connection.execute(query)
42
43
  end
43
44
 
44
- def run_vacuum(schema:)
45
+ def run_vacuum(schema:, table_names: nil)
45
46
  tables_with_dead_tuples = dead_tuples(schema:).pluck('relname')
47
+ tables_with_dead_tuples &= Array(table_names) if table_names
46
48
  AfterMigrate.log("Vacuuming #{tables_with_dead_tuples.size} tables in schema #{schema}...")
47
- tables_with_dead_tuples.each { |t| vacuum(t) }
49
+ tables_with_dead_tuples.each { |t| vacuum(t, schema:, verbose: AfterMigrate.configuration.verbose) }
48
50
  tables_with_dead_tuples
49
51
  end
50
52
 
@@ -63,7 +65,9 @@ module AfterMigrate
63
65
 
64
66
  def optimize_tables(table_names:, schema:, **)
65
67
  cleaned_tables = []
66
- cleaned_tables = run_vacuum(schema:) if AfterMigrate.configuration.vacuum
68
+ cleaned_tables = run_vacuum(schema:, table_names:) if AfterMigrate.configuration.vacuum
69
+
70
+ return if AfterMigrate.configuration.analyze == 'none'
67
71
 
68
72
  tables = table_names - cleaned_tables
69
73
  run_analyze(schema:, tables:)
@@ -3,8 +3,8 @@
3
3
  module AfterMigrate
4
4
  module Sql
5
5
  IDENT = /
6
- (?:"[\w]+"|\w+)
7
- (?:\.(?:"[\w]+"|\w+))*
6
+ (?:"\w+"|\w+)
7
+ (?:\.(?:"\w+"|\w+))*
8
8
  /x
9
9
 
10
10
  PATTERNS = {
@@ -12,6 +12,8 @@ module AfterMigrate
12
12
  module_function
13
13
 
14
14
  def call(schema: nil)
15
+ return unless AfterMigrate.configuration.vacuum || AfterMigrate.configuration.analyze != 'none'
16
+
15
17
  tables = target_tables
16
18
  return if tables.blank?
17
19
 
@@ -32,7 +34,8 @@ module AfterMigrate
32
34
  table_names = tables.to_a.sort
33
35
  return if table_names.empty?
34
36
 
35
- AfterMigrate.log("Migration touched #{table_names.size} table(s) in schema #{schema.inspect}: #{table_names.join(', ')}")
37
+ message = "Migration touched #{table_names.size} table(s) in schema #{schema.inspect}: #{table_names.join(', ')}"
38
+ AfterMigrate.log(message)
36
39
  optimize_tables(schema:, table_names:)
37
40
  end
38
41
 
@@ -56,10 +59,9 @@ module AfterMigrate
56
59
  AfterMigrate.affected_tables.keys.each_with_object({}) do |schema, hash|
57
60
  hash[schema] = all_tables(schema:)
58
61
  end
59
- when 'only_affected_tables'
60
- AfterMigrate.affected_tables
61
62
  else
62
- {}
63
+ # 'only_affected_tables' or 'none' — vacuum still needs the affected list
64
+ AfterMigrate.affected_tables
63
65
  end
64
66
  end
65
67
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AfterMigrate
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.1'
5
5
  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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikolay Moskvin
@@ -53,11 +53,13 @@ files:
53
53
  - ".ruby-version"
54
54
  - ".travis.yml"
55
55
  - CHANGELOG.md
56
+ - CLAUDE.md
56
57
  - Gemfile
57
58
  - README.md
58
59
  - Rakefile
59
60
  - after_migrate.gemspec
60
61
  - bin/console
62
+ - bin/release
61
63
  - bin/setup
62
64
  - lib/after_migrate.rb
63
65
  - lib/after_migrate/adapters/mysql.rb