actual_db_schema 0.8.5 → 0.9.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 +4 -4
- data/CHANGELOG.md +8 -1
- data/Gemfile.lock +3 -3
- data/README.md +120 -27
- data/actual_db_schema.gemspec +4 -3
- data/app/views/actual_db_schema/migrations/show.html.erb +7 -1
- data/app/views/actual_db_schema/phantom_migrations/show.html.erb +7 -1
- data/app/views/actual_db_schema/shared/_style.html +12 -0
- data/lib/actual_db_schema/configuration.rb +26 -8
- data/lib/actual_db_schema/engine.rb +30 -0
- data/lib/actual_db_schema/git_hooks.rb +0 -7
- data/lib/actual_db_schema/migration.rb +5 -4
- data/lib/actual_db_schema/patches/migration_context.rb +2 -2
- data/lib/actual_db_schema/schema_diff.rb +4 -4
- data/lib/actual_db_schema/store.rb +280 -19
- data/lib/actual_db_schema/version.rb +1 -1
- data/lib/generators/actual_db_schema/templates/actual_db_schema.rb +7 -2
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d7f7a42aa9ad38de5eeb7a64ca7a8a9117485a927d536f666aa2bf41be97b9f
|
|
4
|
+
data.tar.gz: 2236fd011c7aa72be7ffeef9d975da3bde1d6dd6844366cb458f51afa479395e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ed9ec959e789377596103e2be9214d5374551bbba0ab17a037cd47bc6481f035adf1b7a24bc2254702d8a64eead1fc503ee4d2317a55596258d7284500f5fd3
|
|
7
|
+
data.tar.gz: 48fc36d62e62e810d6d1fd4c8e3b578654587ac13f9ad8e0245e3e08c5cecf1a683ae3292715b366d0d5ce5b11cebcf953963aed8460664ddd7b0af9d7f1af7b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
## 0.
|
|
1
|
+
## [0.9.0] - 2026-01-27
|
|
2
|
+
- Store migration files in the DB to avoid reliance on the filesystem, enabling CI/CD usage on platforms with ephemeral storage (e.g., Heroku, Docker).
|
|
3
|
+
|
|
4
|
+
## [0.8.6] - 2025-05-21
|
|
5
|
+
- Fix gem installtion with git hooks
|
|
6
|
+
- Update README
|
|
7
|
+
|
|
8
|
+
## [0.8.5] - 2025-04-10
|
|
2
9
|
|
|
3
10
|
- Fix the gem working on projects without git
|
|
4
11
|
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
actual_db_schema (0.
|
|
4
|
+
actual_db_schema (0.9.0)
|
|
5
5
|
activerecord
|
|
6
6
|
activesupport
|
|
7
7
|
ast
|
|
@@ -96,7 +96,7 @@ GEM
|
|
|
96
96
|
concurrent-ruby (1.2.2)
|
|
97
97
|
connection_pool (2.4.1)
|
|
98
98
|
crass (1.0.6)
|
|
99
|
-
csv (3.3.
|
|
99
|
+
csv (3.3.5)
|
|
100
100
|
date (3.3.3)
|
|
101
101
|
debug (1.8.0)
|
|
102
102
|
irb (>= 1.5.0)
|
|
@@ -146,7 +146,7 @@ GEM
|
|
|
146
146
|
parser (3.2.2.4)
|
|
147
147
|
ast (~> 2.4.1)
|
|
148
148
|
racc
|
|
149
|
-
prism (1.
|
|
149
|
+
prism (1.8.0)
|
|
150
150
|
psych (5.1.1.1)
|
|
151
151
|
stringio
|
|
152
152
|
racc (1.7.3)
|
data/README.md
CHANGED
|
@@ -2,31 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
# ActualDbSchema
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Stop database headaches when switching Git branches in Rails**
|
|
6
|
+
|
|
7
|
+
Keep your database schema perfectly synchronized across Git branches, eliminate broken tests and schema conflicts, and save wasted hours on phantom migrations.
|
|
8
|
+
|
|
9
|
+
## 🚀 What You Get
|
|
10
|
+
|
|
11
|
+
- **Zero Manual Work**: Switch branches freely - phantom migrations roll back automatically
|
|
12
|
+
- **No More Schema Conflicts**: Clean `schema.rb`/`structure.sql` diffs every time, no irrelevant changes
|
|
13
|
+
- **Error Prevention**: Eliminates `ActiveRecord::NotNullViolation` and similar errors when switching branches
|
|
14
|
+
- **Time Savings**: Stop hunting down which branch has the problematic migration
|
|
15
|
+
- **Team Productivity**: Everyone stays focused on coding, not database maintenance
|
|
16
|
+
- **Staging/Sandbox Sync**: Keep staging and sandbox databases aligned with your current branch code
|
|
17
|
+
- **Visual Management**: Web UI to view and manage migrations across all databases
|
|
18
|
+
|
|
19
|
+
<img width="3024" height="1886" alt="Visual management of Rails DB migrations with ActualDbSchema" src="https://github.com/user-attachments/assets/87cfb7b4-6380-4dad-ab18-6a0633f561b5" />
|
|
20
|
+
|
|
21
|
+
And you get all of that with **zero** changes to your workflow!
|
|
22
|
+
|
|
23
|
+
## 🎯 The Problem This Solves
|
|
24
|
+
|
|
25
|
+
**Before ActualDbSchema:**
|
|
26
|
+
1. Work on Branch A → Add migration → Run migration
|
|
27
|
+
2. Switch to Branch B → Code breaks with database errors
|
|
28
|
+
3. Manually find and rollback the "phantom" migration
|
|
29
|
+
4. Deal with irrelevant `schema.rb` diffs
|
|
30
|
+
5. Repeat this tedious process constantly
|
|
31
|
+
|
|
32
|
+
**After ActualDbSchema:**
|
|
33
|
+
1. Work on any branch → Add migrations as usual
|
|
34
|
+
2. Switch branches freely → Everything just works
|
|
35
|
+
3. Focus on building features, not fixing database issues
|
|
36
|
+
|
|
37
|
+
## 🌟 Complete Feature Set
|
|
38
|
+
|
|
39
|
+
### Core Migration Management
|
|
40
|
+
- **Phantom Migration Detection**: Automatically identifies migrations from other branches
|
|
41
|
+
- **Smart Rollback**: Rolls back phantom migrations in correct dependency order
|
|
42
|
+
- **Irreversible Migration Handling**: Safely handles and reports irreversible migrations
|
|
43
|
+
- **Multi-Database Support**: Works seamlessly with multiple database configurations
|
|
44
|
+
- **Schema Format Agnostic**: Supports both `schema.rb` and `structure.sql`
|
|
45
|
+
|
|
46
|
+
### Automation & Git Integration
|
|
47
|
+
- **Automatic Rollback on Migration**: Phantom migrations roll back when running `db:migrate`
|
|
48
|
+
- **Git Hook Integration**: Optional automatic rollback when switching branches
|
|
49
|
+
- **Zero Configuration**: Works out of the box with sensible defaults
|
|
50
|
+
- **Custom Migration Storage**: Configurable location for storing executed migrations
|
|
51
|
+
|
|
52
|
+
### Web Interface & Management
|
|
53
|
+
- **Migration Dashboard**: Visual overview of all migrations across databases
|
|
54
|
+
- **Phantom Migration Browser**: Easy-to-use interface for viewing phantom migrations
|
|
55
|
+
- **One-Click Rollback**: Rollback individual or all phantom migrations via web UI
|
|
56
|
+
- **Broken Version Cleanup**: Identify and remove orphaned migration records
|
|
57
|
+
- **Schema Diff Viewer**: Visual diff of schema changes with migration annotations
|
|
58
|
+
|
|
59
|
+
### Developer Tools
|
|
60
|
+
- **Console Migrations**: Run migration commands directly in Rails console
|
|
61
|
+
- **Schema Diff Analysis**: Annotated diffs showing which migrations caused changes
|
|
62
|
+
- **Migration Search & Filter**: Find specific migrations across all databases
|
|
63
|
+
- **Detailed Migration Info**: View migration status, branch, and database information
|
|
64
|
+
|
|
65
|
+
### Team & Environment Support
|
|
66
|
+
- **Multi-Tenant Compatible**: Works with apartment gem and similar multi-tenant setups
|
|
67
|
+
- **Environment Flexibility**: Enable/disable features per environment
|
|
68
|
+
- **Team Synchronization**: Keeps all team members' databases in sync
|
|
69
|
+
- **CI/CD Friendly**: No interference with deployment pipelines
|
|
70
|
+
|
|
71
|
+
### Manual Control Options
|
|
72
|
+
- **Manual Rollback Mode**: Disable automatic rollback for full manual control
|
|
73
|
+
- **Selective Rollback**: Choose which phantom migrations to rollback
|
|
74
|
+
- **Interactive Mode**: Step-by-step confirmation for each rollback operation
|
|
75
|
+
- **Rake Task Integration**: Full set of rake tasks for command-line management
|
|
76
|
+
|
|
77
|
+
## ⚡ Quick Start
|
|
78
|
+
|
|
79
|
+
Add to your Gemfile:
|
|
6
80
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
### The problem definition
|
|
14
|
-
|
|
15
|
-
Imagine you're working on **branch A**. You add a not-null column to a database table with a migration. You run the migration. Then you switch to **branch B**. The code in **branch B** isn't aware of this newly added field. When it tries to write data to the table, it fails with an error `null value provided for non-null field`. Why? The existing code is writing a null value into the column with a not-null constraint.
|
|
16
|
-
|
|
17
|
-
Here's an example of this error:
|
|
18
|
-
|
|
19
|
-
ActiveRecord::NotNullViolation:
|
|
20
|
-
PG::NotNullViolation: ERROR: null value in column "log" of relation "check_results" violates not-null constraint
|
|
21
|
-
DETAIL: Failing row contains (8, 46, success, 2022-10-16 21:47:21.07212, 2022-10-16 21:47:21.07212, null).
|
|
81
|
+
```ruby
|
|
82
|
+
group :development do
|
|
83
|
+
gem "actual_db_schema"
|
|
84
|
+
end
|
|
85
|
+
```
|
|
22
86
|
|
|
23
|
-
|
|
87
|
+
Install and configure:
|
|
24
88
|
|
|
25
|
-
|
|
89
|
+
```sh
|
|
90
|
+
bundle install
|
|
91
|
+
rails actual_db_schema:install
|
|
92
|
+
```
|
|
26
93
|
|
|
27
|
-
|
|
94
|
+
That's it! Now just run `rails db:migrate` as usual - phantom migrations roll back automatically.
|
|
28
95
|
|
|
29
|
-
|
|
96
|
+
## 🔧 How It Works
|
|
30
97
|
|
|
31
98
|
This gem stores all run migrations with their code in the `tmp/migrated` folder. Whenever you perform a schema dump, it rolls back the *phantom migrations*.
|
|
32
99
|
|
|
@@ -53,10 +120,10 @@ If you cannot commit changes to the repo or Gemfile, consider the local Gemfile
|
|
|
53
120
|
Next, generate your ActualDbSchema initializer file by running:
|
|
54
121
|
|
|
55
122
|
```sh
|
|
56
|
-
|
|
123
|
+
rails actual_db_schema:install
|
|
57
124
|
```
|
|
58
125
|
|
|
59
|
-
This will create a `config/initializers/actual_db_schema.rb` file
|
|
126
|
+
This will create a `config/initializers/actual_db_schema.rb` file that lists all available configuration options, allowing you to customize them as needed. The installation process will also prompt you to install the post-checkout Git hook, which automatically rolls back phantom migrations when switching branches. If enabled, this hook will run the schema actualization rake task every time you switch branches, which can slow down branch changes. Therefore, you might not always want this automatic actualization on every switch; in that case, running `rails db:migrate` manually provides a faster, more controlled alternative.
|
|
60
127
|
|
|
61
128
|
For more details on the available configuration options, see the sections below.
|
|
62
129
|
|
|
@@ -72,7 +139,7 @@ The gem offers the following rake tasks that can be manually run according to yo
|
|
|
72
139
|
- `rails db:rollback_branches:manual` - run it to manually rolls back phantom migrations one by one.
|
|
73
140
|
- `rails db:phantom_migrations` - displays a list of phantom migrations.
|
|
74
141
|
|
|
75
|
-
##
|
|
142
|
+
## 🎛️ Configuration Options
|
|
76
143
|
|
|
77
144
|
By default, `actual_db_schema` stores all run migrations in the `tmp/migrated` folder. However, if you want to change this location, you can configure it in two ways:
|
|
78
145
|
|
|
@@ -91,13 +158,37 @@ Add the following line to your initializer file (`config/initializers/actual_db_
|
|
|
91
158
|
config.migrated_folder = Rails.root.join("custom", "migrated")
|
|
92
159
|
```
|
|
93
160
|
|
|
94
|
-
|
|
161
|
+
### 3. Store migrations in the database
|
|
95
162
|
|
|
96
|
-
|
|
163
|
+
If you want to share executed migrations across environments (e.g., staging or sandboxes),
|
|
164
|
+
store them in the main database instead of the local filesystem:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
config.migrations_storage = :db
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Or via environment variable:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
export ACTUAL_DB_SCHEMA_MIGRATIONS_STORAGE="db"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
If both are set, the initializer setting (`config.migrations_storage`) takes precedence.
|
|
177
|
+
|
|
178
|
+
## 🌐 Web Interface
|
|
179
|
+
|
|
180
|
+
Access the migration management UI at:
|
|
97
181
|
```
|
|
98
182
|
http://localhost:3000/rails/phantom_migrations
|
|
99
183
|
```
|
|
100
|
-
|
|
184
|
+
|
|
185
|
+
View and manage:
|
|
186
|
+
- **Migration Overview**: See all executed migrations with their status, branch, and database
|
|
187
|
+
- **Phantom Migrations**: Identify migrations from other branches that need rollback
|
|
188
|
+
- **Migration Source Code**: Browse the source code of every migration ever run (including the phantom ones)
|
|
189
|
+
- **One-Click Actions**: Rollback or migrate individual migrations directly from the UI
|
|
190
|
+
- **Broken Versions**: Detect and clean up orphaned migration records safely
|
|
191
|
+
- **Schema Diffs**: Visual diff of schema changes annotated with their source migrations
|
|
101
192
|
|
|
102
193
|
## UI options
|
|
103
194
|
|
|
@@ -193,6 +284,8 @@ If `schema.rb` generates a diff, it can be helpful to find out which migrations
|
|
|
193
284
|
|
|
194
285
|
By default, the task uses `db/schema.rb` and `db/migrate` as the schema and migrations paths. You can also provide custom paths as arguments.
|
|
195
286
|
|
|
287
|
+
Alternatively, if you use Web UI, you can see this diff at `http://localhost:3000/rails/schema`. This way is often more convenient than running the Rake task manually.
|
|
288
|
+
|
|
196
289
|
### Usage
|
|
197
290
|
|
|
198
291
|
Run the task with default paths:
|
|
@@ -290,7 +383,7 @@ rake actual_db_schema:delete_broken_versions["20250224103352 20250224103358"]
|
|
|
290
383
|
rake actual_db_schema:delete_broken_versions["20250224103352 20250224103358", "primary"]
|
|
291
384
|
```
|
|
292
385
|
|
|
293
|
-
## Development
|
|
386
|
+
## 🏗️ Development
|
|
294
387
|
|
|
295
388
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
296
389
|
|
data/actual_db_schema.gemspec
CHANGED
|
@@ -8,10 +8,11 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["Andrei Kaleshka"]
|
|
9
9
|
spec.email = ["ka8725@gmail.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "Keep
|
|
11
|
+
spec.summary = "Keep DB schema in sync across branches effortlessly."
|
|
12
12
|
spec.description = <<~DESC
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
Keep your DB schema in sync across all branches effortlessly.
|
|
14
|
+
Install once, then use `rails db:migrate` normally — the gem handles phantom migration rollback automatically, eliminating schema conflicts and inconsistent database states.
|
|
15
|
+
Stop wasting hours on DB maintenance locally, CI, staging/sandbox, or even production.
|
|
15
16
|
DESC
|
|
16
17
|
spec.homepage = "https://blog.widefix.com/actual-db-schema/"
|
|
17
18
|
spec.license = "MIT"
|
|
@@ -31,7 +31,13 @@
|
|
|
31
31
|
</tr>
|
|
32
32
|
<tr>
|
|
33
33
|
<th>Path</th>
|
|
34
|
-
<td
|
|
34
|
+
<td>
|
|
35
|
+
<%= migration[:filename] %>
|
|
36
|
+
<% source = migration[:source].to_s %>
|
|
37
|
+
<% if source.present? %>
|
|
38
|
+
<span class="source-badge"><%= source.upcase %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
</td>
|
|
35
41
|
</tr>
|
|
36
42
|
</tbody>
|
|
37
43
|
</table>
|
|
@@ -31,7 +31,13 @@
|
|
|
31
31
|
</tr>
|
|
32
32
|
<tr>
|
|
33
33
|
<th>Path</th>
|
|
34
|
-
<td
|
|
34
|
+
<td>
|
|
35
|
+
<%= phantom_migration[:filename] %>
|
|
36
|
+
<% source = phantom_migration[:source].to_s %>
|
|
37
|
+
<% if source.present? %>
|
|
38
|
+
<span class="source-badge"><%= source.upcase %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
</td>
|
|
35
41
|
</tr>
|
|
36
42
|
</tbody>
|
|
37
43
|
</table>
|
|
@@ -158,4 +158,16 @@
|
|
|
158
158
|
.schema-diff {
|
|
159
159
|
margin-left: 8px;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
.source-badge {
|
|
163
|
+
display: inline-block;
|
|
164
|
+
margin-left: 8px;
|
|
165
|
+
padding: 2px 6px;
|
|
166
|
+
border-radius: 10px;
|
|
167
|
+
font-size: 11px;
|
|
168
|
+
font-weight: bold;
|
|
169
|
+
letter-spacing: 0.3px;
|
|
170
|
+
background-color: #e8f1ff;
|
|
171
|
+
color: #1d4ed8;
|
|
172
|
+
}
|
|
161
173
|
</style>
|
|
@@ -4,16 +4,10 @@ module ActualDbSchema
|
|
|
4
4
|
# Manages the configuration settings for the gem.
|
|
5
5
|
class Configuration
|
|
6
6
|
attr_accessor :enabled, :auto_rollback_disabled, :ui_enabled, :git_hooks_enabled, :multi_tenant_schemas,
|
|
7
|
-
:console_migrations_enabled, :migrated_folder
|
|
7
|
+
:console_migrations_enabled, :migrated_folder, :migrations_storage
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
|
-
|
|
11
|
-
@auto_rollback_disabled = ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?
|
|
12
|
-
@ui_enabled = Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
|
|
13
|
-
@git_hooks_enabled = ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?
|
|
14
|
-
@multi_tenant_schemas = nil
|
|
15
|
-
@console_migrations_enabled = ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?
|
|
16
|
-
@migrated_folder = ENV["ACTUAL_DB_SCHEMA_MIGRATED_FOLDER"].present?
|
|
10
|
+
apply_defaults(default_settings)
|
|
17
11
|
end
|
|
18
12
|
|
|
19
13
|
def [](key)
|
|
@@ -22,6 +16,9 @@ module ActualDbSchema
|
|
|
22
16
|
|
|
23
17
|
def []=(key, value)
|
|
24
18
|
public_send("#{key}=", value)
|
|
19
|
+
return unless key.to_sym == :migrations_storage && defined?(ActualDbSchema::Store)
|
|
20
|
+
|
|
21
|
+
ActualDbSchema::Store.instance.reset_adapter
|
|
25
22
|
end
|
|
26
23
|
|
|
27
24
|
def fetch(key, default = nil)
|
|
@@ -31,5 +28,26 @@ module ActualDbSchema
|
|
|
31
28
|
default
|
|
32
29
|
end
|
|
33
30
|
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def default_settings
|
|
35
|
+
{
|
|
36
|
+
enabled: Rails.env.development?,
|
|
37
|
+
auto_rollback_disabled: ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?,
|
|
38
|
+
ui_enabled: Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?,
|
|
39
|
+
git_hooks_enabled: ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?,
|
|
40
|
+
multi_tenant_schemas: nil,
|
|
41
|
+
console_migrations_enabled: ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?,
|
|
42
|
+
migrated_folder: ENV["ACTUAL_DB_SCHEMA_MIGRATED_FOLDER"].present?,
|
|
43
|
+
migrations_storage: ENV.fetch("ACTUAL_DB_SCHEMA_MIGRATIONS_STORAGE", "file").to_sym
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply_defaults(settings)
|
|
48
|
+
settings.each do |key, value|
|
|
49
|
+
instance_variable_set("@#{key}", value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
34
52
|
end
|
|
35
53
|
end
|
|
@@ -12,5 +12,35 @@ module ActualDbSchema
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
initializer "actual_db_schema.schema_dump_exclusions" do
|
|
17
|
+
ActiveSupport.on_load(:active_record) do
|
|
18
|
+
table_name = ActualDbSchema::Store::DbAdapter::TABLE_NAME
|
|
19
|
+
|
|
20
|
+
if defined?(ActiveRecord::SchemaDumper) && ActiveRecord::SchemaDumper.respond_to?(:ignore_tables)
|
|
21
|
+
ActiveRecord::SchemaDumper.ignore_tables |= [table_name]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
next unless defined?(ActiveRecord::Tasks::DatabaseTasks)
|
|
25
|
+
next unless ActiveRecord::Tasks::DatabaseTasks.respond_to?(:structure_dump_flags)
|
|
26
|
+
|
|
27
|
+
flags = Array(ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags)
|
|
28
|
+
adapter = ActualDbSchema.db_config[:adapter].to_s
|
|
29
|
+
database = ActualDbSchema.db_config[:database]
|
|
30
|
+
if database.nil? && ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
31
|
+
database = ActiveRecord::Base.connection_db_config&.database
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if adapter.match?(/postgres/i)
|
|
35
|
+
flag = "--exclude-table=#{table_name}*"
|
|
36
|
+
flags << flag unless flags.include?(flag)
|
|
37
|
+
elsif adapter.match?(/mysql/i) && database
|
|
38
|
+
flag = "--ignore-table=#{database}.#{table_name}"
|
|
39
|
+
flags << flag unless flags.include?(flag)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags
|
|
43
|
+
end
|
|
44
|
+
end
|
|
15
45
|
end
|
|
16
46
|
end
|
|
@@ -63,7 +63,6 @@ module ActualDbSchema
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def install_post_checkout_hook
|
|
66
|
-
return unless git_hooks_enabled?
|
|
67
66
|
return unless hooks_directory_present?
|
|
68
67
|
|
|
69
68
|
if File.exist?(hook_path)
|
|
@@ -87,12 +86,6 @@ module ActualDbSchema
|
|
|
87
86
|
@hook_path ||= hooks_dir.join("post-checkout")
|
|
88
87
|
end
|
|
89
88
|
|
|
90
|
-
def git_hooks_enabled?
|
|
91
|
-
return true if ActualDbSchema.config[:git_hooks_enabled]
|
|
92
|
-
|
|
93
|
-
puts colorize("[ActualDbSchema] Git hooks are disabled in configuration. Skipping installation.", :gray)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
89
|
def hooks_directory_present?
|
|
97
90
|
return true if Dir.exist?(hooks_dir)
|
|
98
91
|
|
|
@@ -5,7 +5,8 @@ module ActualDbSchema
|
|
|
5
5
|
class Migration
|
|
6
6
|
include Singleton
|
|
7
7
|
|
|
8
|
-
Migration = Struct.new(:status, :version, :name, :branch, :database, :filename, :phantom,
|
|
8
|
+
Migration = Struct.new(:status, :version, :name, :branch, :database, :filename, :phantom, :source,
|
|
9
|
+
keyword_init: true)
|
|
9
10
|
|
|
10
11
|
def all_phantom
|
|
11
12
|
migrations = []
|
|
@@ -120,7 +121,8 @@ module ActualDbSchema
|
|
|
120
121
|
branch: branch_for(migration.version),
|
|
121
122
|
database: ActualDbSchema.db_config[:database],
|
|
122
123
|
filename: migration.filename,
|
|
123
|
-
phantom: phantom?(migration)
|
|
124
|
+
phantom: phantom?(migration),
|
|
125
|
+
source: ActualDbSchema::Store.instance.source_for(migration.version)
|
|
124
126
|
)
|
|
125
127
|
end
|
|
126
128
|
|
|
@@ -129,8 +131,7 @@ module ActualDbSchema
|
|
|
129
131
|
end
|
|
130
132
|
|
|
131
133
|
def phantom?(migration)
|
|
132
|
-
|
|
133
|
-
migration.filename.include?(migrated_folder.to_s)
|
|
134
|
+
ActualDbSchema::Store.instance.stored_migration?(migration.filename)
|
|
134
135
|
end
|
|
135
136
|
|
|
136
137
|
def should_include?(status, migration)
|
|
@@ -71,7 +71,7 @@ module ActualDbSchema
|
|
|
71
71
|
def migration_files
|
|
72
72
|
paths = Array(migrations_paths)
|
|
73
73
|
current_branch_files = Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }]
|
|
74
|
-
other_branches_files =
|
|
74
|
+
other_branches_files = ActualDbSchema::Store.instance.migration_files
|
|
75
75
|
current_branch_versions = current_branch_files.map { |file| file.match(/(\d+)_/)[1] }
|
|
76
76
|
filtered_other_branches_files = other_branches_files.reject do |file|
|
|
77
77
|
version = file.match(/(\d+)_/)[1]
|
|
@@ -163,7 +163,7 @@ module ActualDbSchema
|
|
|
163
163
|
|
|
164
164
|
migrations.uniq.each do |migration|
|
|
165
165
|
count = migration_counts[migration.filename]
|
|
166
|
-
|
|
166
|
+
ActualDbSchema::Store.instance.delete(migration.filename) if count == schema_count
|
|
167
167
|
end
|
|
168
168
|
end
|
|
169
169
|
|
|
@@ -63,12 +63,12 @@ module ActualDbSchema
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def migrated_folders
|
|
66
|
+
ActualDbSchema::Store.instance.materialize_all
|
|
66
67
|
dirs = find_migrated_folders
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
end
|
|
69
|
+
configured_migrated_folder = ActualDbSchema.migrated_folder
|
|
70
|
+
relative_migrated_folder = configured_migrated_folder.to_s.sub(%r{\A#{Regexp.escape(Rails.root.to_s)}/?}, "")
|
|
71
|
+
dirs << relative_migrated_folder unless dirs.include?(relative_migrated_folder)
|
|
72
72
|
|
|
73
73
|
dirs.map { |dir| dir.sub(%r{\A\./}, "") }.uniq
|
|
74
74
|
end
|
|
@@ -1,44 +1,305 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActualDbSchema
|
|
4
|
-
# Stores
|
|
4
|
+
# Stores migration sources and metadata.
|
|
5
5
|
class Store
|
|
6
6
|
include Singleton
|
|
7
7
|
|
|
8
8
|
Item = Struct.new(:version, :timestamp, :branch)
|
|
9
9
|
|
|
10
10
|
def write(filename)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
FileUtils.copy(filename, folder.join(basename))
|
|
14
|
-
record_metadata(filename)
|
|
11
|
+
adapter.write(filename)
|
|
12
|
+
reset_source_cache
|
|
15
13
|
end
|
|
16
14
|
|
|
17
15
|
def read
|
|
18
|
-
|
|
16
|
+
adapter.read
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def migration_files
|
|
20
|
+
adapter.migration_files
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def delete(filename)
|
|
24
|
+
adapter.delete(filename)
|
|
25
|
+
reset_source_cache
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stored_migration?(filename)
|
|
29
|
+
adapter.stored_migration?(filename)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def source_for(version)
|
|
33
|
+
version = version.to_s
|
|
34
|
+
|
|
35
|
+
return :db if db_versions.key?(version)
|
|
36
|
+
return :file if file_versions.key?(version)
|
|
37
|
+
|
|
38
|
+
:unknown
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def materialize_all
|
|
42
|
+
adapter.materialize_all
|
|
43
|
+
end
|
|
19
44
|
|
|
20
|
-
|
|
45
|
+
def reset_adapter
|
|
46
|
+
@adapter = nil
|
|
47
|
+
reset_source_cache
|
|
21
48
|
end
|
|
22
49
|
|
|
23
50
|
private
|
|
24
51
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
def adapter
|
|
53
|
+
@adapter ||= begin
|
|
54
|
+
storage = ActualDbSchema.config[:migrations_storage].to_s
|
|
55
|
+
storage == "db" ? DbAdapter.new : FileAdapter.new
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reset_source_cache
|
|
60
|
+
@db_versions = nil
|
|
61
|
+
@file_versions = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def db_versions
|
|
65
|
+
@db_versions ||= begin
|
|
66
|
+
connection = ActiveRecord::Base.connection
|
|
67
|
+
return {} unless connection.table_exists?(DbAdapter::TABLE_NAME)
|
|
68
|
+
|
|
69
|
+
table = connection.quote_table_name(DbAdapter::TABLE_NAME)
|
|
70
|
+
connection.select_values("SELECT version FROM #{table}").each_with_object({}) do |version, acc|
|
|
71
|
+
acc[version.to_s] = true
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError
|
|
74
|
+
{}
|
|
33
75
|
end
|
|
34
76
|
end
|
|
35
77
|
|
|
36
|
-
def
|
|
37
|
-
|
|
78
|
+
def file_versions
|
|
79
|
+
@file_versions ||= FileAdapter.new.read
|
|
80
|
+
rescue StandardError
|
|
81
|
+
{}
|
|
38
82
|
end
|
|
39
83
|
|
|
40
|
-
|
|
41
|
-
|
|
84
|
+
# Stores migrated files on the filesystem with metadata in CSV.
|
|
85
|
+
class FileAdapter
|
|
86
|
+
def write(filename)
|
|
87
|
+
basename = File.basename(filename)
|
|
88
|
+
FileUtils.mkdir_p(folder)
|
|
89
|
+
FileUtils.copy(filename, folder.join(basename))
|
|
90
|
+
record_metadata(filename)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read
|
|
94
|
+
return {} unless File.exist?(store_file)
|
|
95
|
+
|
|
96
|
+
CSV.read(store_file).map { |line| Item.new(*line) }.index_by(&:version)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def migration_files
|
|
100
|
+
Dir["#{folder}/**/[0-9]*_*.rb"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def delete(filename)
|
|
104
|
+
File.delete(filename) if File.exist?(filename)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stored_migration?(filename)
|
|
108
|
+
filename.to_s.start_with?(folder.to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def materialize_all
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def record_metadata(filename)
|
|
118
|
+
version = File.basename(filename).scan(/(\d+)_.*\.rb/).first.first
|
|
119
|
+
CSV.open(store_file, "a") do |csv|
|
|
120
|
+
csv << [
|
|
121
|
+
version,
|
|
122
|
+
Time.current.iso8601,
|
|
123
|
+
Git.current_branch
|
|
124
|
+
]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def folder
|
|
129
|
+
ActualDbSchema.migrated_folder
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def store_file
|
|
133
|
+
folder.join("metadata.csv")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Stores migrated files in the database.
|
|
138
|
+
class DbAdapter
|
|
139
|
+
TABLE_NAME = "actual_db_schema_migrations"
|
|
140
|
+
RECORD_COLUMNS = %w[version filename content branch migrated_at].freeze
|
|
141
|
+
|
|
142
|
+
def write(filename)
|
|
143
|
+
ensure_table!
|
|
144
|
+
|
|
145
|
+
version = extract_version(filename)
|
|
146
|
+
return unless version
|
|
147
|
+
|
|
148
|
+
basename = File.basename(filename)
|
|
149
|
+
content = File.read(filename)
|
|
150
|
+
upsert_record(version, basename, content, Git.current_branch, Time.current)
|
|
151
|
+
write_cache_file(basename, content)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read
|
|
155
|
+
return {} unless table_exists?
|
|
156
|
+
|
|
157
|
+
rows = connection.exec_query(<<~SQL.squish)
|
|
158
|
+
SELECT version, migrated_at, branch
|
|
159
|
+
FROM #{quoted_table}
|
|
160
|
+
SQL
|
|
161
|
+
|
|
162
|
+
rows.map do |row|
|
|
163
|
+
Item.new(row["version"].to_s, row["migrated_at"], row["branch"])
|
|
164
|
+
end.index_by(&:version)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def migration_files
|
|
168
|
+
materialize_all
|
|
169
|
+
Dir["#{folder}/**/[0-9]*_*.rb"]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def delete(filename)
|
|
173
|
+
version = extract_version(filename)
|
|
174
|
+
return unless version
|
|
175
|
+
|
|
176
|
+
if table_exists?
|
|
177
|
+
connection.execute(<<~SQL.squish)
|
|
178
|
+
DELETE FROM #{quoted_table}
|
|
179
|
+
WHERE #{quoted_column("version")} = #{connection.quote(version)}
|
|
180
|
+
SQL
|
|
181
|
+
end
|
|
182
|
+
File.delete(filename) if File.exist?(filename)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def stored_migration?(filename)
|
|
186
|
+
filename.to_s.start_with?(folder.to_s)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def materialize_all
|
|
190
|
+
return unless table_exists?
|
|
191
|
+
|
|
192
|
+
FileUtils.mkdir_p(folder)
|
|
193
|
+
rows = connection.exec_query(<<~SQL.squish)
|
|
194
|
+
SELECT filename, content
|
|
195
|
+
FROM #{quoted_table}
|
|
196
|
+
SQL
|
|
197
|
+
|
|
198
|
+
rows.each do |row|
|
|
199
|
+
write_cache_file(row["filename"], row["content"])
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def upsert_record(version, basename, content, branch, migrated_at)
|
|
206
|
+
attributes = record_attributes(version, basename, content, branch, migrated_at)
|
|
207
|
+
record_exists?(version) ? update_record(attributes) : insert_record(attributes)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def record_attributes(version, basename, content, branch, migrated_at)
|
|
211
|
+
{
|
|
212
|
+
version: version,
|
|
213
|
+
filename: basename,
|
|
214
|
+
content: content,
|
|
215
|
+
branch: branch,
|
|
216
|
+
migrated_at: migrated_at
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def update_record(attributes)
|
|
221
|
+
assignments = record_columns.reject { |column| column == "version" }.map do |column|
|
|
222
|
+
"#{quoted_column(column)} = #{connection.quote(attributes[column.to_sym])}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
connection.execute(<<~SQL)
|
|
226
|
+
UPDATE #{quoted_table}
|
|
227
|
+
SET #{assignments.join(", ")}
|
|
228
|
+
WHERE #{quoted_column("version")} = #{connection.quote(attributes[:version])}
|
|
229
|
+
SQL
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def insert_record(attributes)
|
|
233
|
+
columns = record_columns
|
|
234
|
+
values = columns.map { |column| connection.quote(attributes[column.to_sym]) }
|
|
235
|
+
|
|
236
|
+
connection.execute(<<~SQL)
|
|
237
|
+
INSERT INTO #{quoted_table}
|
|
238
|
+
(#{columns.map { |column| quoted_column(column) }.join(", ")})
|
|
239
|
+
VALUES
|
|
240
|
+
(#{values.join(", ")})
|
|
241
|
+
SQL
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def record_exists?(version)
|
|
245
|
+
connection.select_value(<<~SQL.squish).present?
|
|
246
|
+
SELECT 1
|
|
247
|
+
FROM #{quoted_table}
|
|
248
|
+
WHERE #{quoted_column("version")} = #{connection.quote(version)}
|
|
249
|
+
LIMIT 1
|
|
250
|
+
SQL
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def ensure_table!
|
|
254
|
+
return if table_exists?
|
|
255
|
+
|
|
256
|
+
connection.create_table(TABLE_NAME) do |t|
|
|
257
|
+
t.string :version, null: false
|
|
258
|
+
t.string :filename, null: false
|
|
259
|
+
t.text :content, null: false
|
|
260
|
+
t.string :branch
|
|
261
|
+
t.datetime :migrated_at, null: false
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
connection.add_index(TABLE_NAME, :version, unique: true) unless connection.index_exists?(TABLE_NAME, :version)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def table_exists?
|
|
268
|
+
connection.table_exists?(TABLE_NAME)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def connection
|
|
272
|
+
ActiveRecord::Base.connection
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def record_columns
|
|
276
|
+
RECORD_COLUMNS
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def quoted_table
|
|
280
|
+
connection.quote_table_name(TABLE_NAME)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def quoted_column(name)
|
|
284
|
+
connection.quote_column_name(name)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def folder
|
|
288
|
+
ActualDbSchema.migrated_folder
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def write_cache_file(filename, content)
|
|
292
|
+
FileUtils.mkdir_p(folder)
|
|
293
|
+
path = folder.join(File.basename(filename))
|
|
294
|
+
return if File.exist?(path) && File.read(path) == content
|
|
295
|
+
|
|
296
|
+
File.write(path, content)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def extract_version(filename)
|
|
300
|
+
match = File.basename(filename).scan(/(\d+)_.*\.rb/).first
|
|
301
|
+
match&.first
|
|
302
|
+
end
|
|
42
303
|
end
|
|
43
304
|
end
|
|
44
305
|
end
|
|
@@ -15,9 +15,10 @@ if defined?(ActualDbSchema)
|
|
|
15
15
|
# Enable the UI for managing migrations.
|
|
16
16
|
config.ui_enabled = Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
|
|
17
17
|
|
|
18
|
-
# Enable automatic phantom migration rollback on branch switch
|
|
18
|
+
# Enable automatic phantom migration rollback on branch switch.
|
|
19
19
|
# config.git_hooks_enabled = true
|
|
20
|
-
|
|
20
|
+
git_hook_enabled_env = ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"]
|
|
21
|
+
config.git_hooks_enabled = git_hook_enabled_env.nil? ? true : git_hook_enabled_env.present?
|
|
21
22
|
|
|
22
23
|
# If your application leverages multiple schemas for multi-tenancy, define the active schemas.
|
|
23
24
|
# config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] }
|
|
@@ -29,5 +30,9 @@ if defined?(ActualDbSchema)
|
|
|
29
30
|
# Define the migrated folder location.
|
|
30
31
|
# config.migrated_folder = Rails.root.join("custom", "migrated")
|
|
31
32
|
config.migrated_folder = Rails.root.join("tmp", "migrated")
|
|
33
|
+
|
|
34
|
+
# Choose where to store migrated files: :file or :db.
|
|
35
|
+
# config.migrations_storage = :db
|
|
36
|
+
config.migrations_storage = :file
|
|
32
37
|
end
|
|
33
38
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: actual_db_schema
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Kaleshka
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -151,8 +151,9 @@ dependencies:
|
|
|
151
151
|
- !ruby/object:Gem::Version
|
|
152
152
|
version: '0'
|
|
153
153
|
description: |
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
Keep your DB schema in sync across all branches effortlessly.
|
|
155
|
+
Install once, then use `rails db:migrate` normally — the gem handles phantom migration rollback automatically, eliminating schema conflicts and inconsistent database states.
|
|
156
|
+
Stop wasting hours on DB maintenance locally, CI, staging/sandbox, or even production.
|
|
156
157
|
email:
|
|
157
158
|
- ka8725@gmail.com
|
|
158
159
|
executables: []
|
|
@@ -253,6 +254,6 @@ requirements: []
|
|
|
253
254
|
rubygems_version: 3.3.26
|
|
254
255
|
signing_key:
|
|
255
256
|
specification_version: 4
|
|
256
|
-
summary: Keep
|
|
257
|
+
summary: Keep DB schema in sync across branches effortlessly.
|
|
257
258
|
test_files: []
|
|
258
259
|
...
|