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 +4 -4
- data/.gitignore +2 -0
- data/.rubocop.yml +12 -0
- data/CLAUDE.md +74 -0
- data/README.md +24 -0
- data/bin/release +85 -0
- data/lib/after_migrate/adapters/postgresql.rb +8 -4
- data/lib/after_migrate/adapters/sql.rb +2 -2
- data/lib/after_migrate/executor.rb +6 -4
- data/lib/after_migrate/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19aeb8ce5d84227e4ea467f7e0d14ff8ccdb34f49214f9201b7550ede7ffbbf3
|
|
4
|
+
data.tar.gz: 2c52a7d680cb37bc2536be8ccd822fee3b92fc6e2b91a5587fa7da1826d14993
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8f671d511d7dc8cecc9a33e8bd90752cd0bb5b5ccb50a485dd7215c50ee68d76ec9ce626850d18717bb507a50e0bfe31e71425d24975607144b3749da1d9345
|
|
7
|
+
data.tar.gz: 80e42d103e33f52644366ebd660a0d5252c55934386830c68f33c9418b37ffbb92310c1c2dc2dd033d9a62204f0bd8d8c78f222852796d5838c4526ae313809e
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
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
|
-
|
|
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:)
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|