migsupo 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9f22d99bd89a407d32621aadf871aa3a6059291b338aa2df6b9f12cd29fa6abf
4
+ data.tar.gz: 555ccc440d31b410b015f4975b3835f590fa15e900c759d166b3ad1cbb842327
5
+ SHA512:
6
+ metadata.gz: c9d49e0b2092d947a31783bad5c5052b658da6be8b383f4473e91d0afa82e59a0f198288fcffd9eeb03b3a787b85771cefa2bac502fd806616d8460bd5419362
7
+ data.tar.gz: cb2ff4341a47c1c646b32cce37dc771b1e85acb1d42d97b42394f8d15f174716707495d118731d8043a8d468eb8fd17414f041ceff7001b23fc4b0539c5c89cb
data/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # Migsupo
2
+
3
+ Migsupo is a Rails gem that generates migration files from the diff between a **Schemafile** (your desired schema) and the current database state.
4
+
5
+ It is inspired by [ridgepole](https://github.com/ridgepole/ridgepole) but with a key difference: instead of applying schema changes directly to the database, Migsupo generates standard Rails migration files that you can review, modify, and run through the normal `rails db:migrate` workflow.
6
+
7
+ ## How It Works
8
+
9
+ ```
10
+ Schemafile → migsupo → db/migrate/*.rb → rails db:migrate → DB
11
+ (desired state) (auto-generated) ↓
12
+ db/schema.rb
13
+ (managed by Rails)
14
+ ```
15
+
16
+ You define your desired schema in a `Schemafile` using the same DSL as ridgepole. Migsupo compares it against the current database and generates migration files for any differences.
17
+
18
+ ### File Responsibilities
19
+
20
+ | File | Managed by | Purpose |
21
+ |---|---|---|
22
+ | `Schemafile` | You | Declares the desired schema state |
23
+ | `db/migrate/*.rb` | Migsupo (generated) + you (reviewed) | Incremental changes to apply |
24
+ | `db/schema.rb` | Rails | Snapshot of the current schema after migrations — do not edit manually |
25
+
26
+ The `Schemafile` and `db/schema.rb` are intentionally separate. Rails owns `db/schema.rb` and keeps it in sync after every `rails db:migrate`. The `Schemafile` is yours to manage — Rails never touches it.
27
+
28
+ ## Installation
29
+
30
+ Add to your `Gemfile`:
31
+
32
+ ```ruby
33
+ gem "migsupo"
34
+ ```
35
+
36
+ Then run:
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ ## Getting Started
43
+
44
+ ### 1. Create a Schemafile
45
+
46
+ Create a `Schemafile` in your Rails root. For existing projects, you can use `db/schema.rb` as a reference — copy the `create_table` and `add_index` blocks as-is (without the `ActiveRecord::Schema.define` wrapper).
47
+
48
+ ```bash
49
+ # Example: use schema.rb as a starting point
50
+ grep -v "ActiveRecord::Schema\|^end$\|^#\|version:" db/schema.rb > Schemafile
51
+ ```
52
+
53
+ From this point on, the `Schemafile` is yours to manage. Rails will not touch it.
54
+
55
+ ### 2. Edit the Schemafile to describe your desired schema
56
+
57
+ ```ruby
58
+ # Schemafile
59
+
60
+ create_table "users", force: :cascade do |t|
61
+ t.string "name", null: false
62
+ t.string "email", null: false
63
+ t.integer "age"
64
+ t.timestamps
65
+ end
66
+
67
+ add_index "users", ["email"], name: "index_users_on_email", unique: true
68
+
69
+ create_table "posts", force: :cascade do |t|
70
+ t.string "title", null: false
71
+ t.text "body"
72
+ t.references "user"
73
+ t.timestamps
74
+ end
75
+ ```
76
+
77
+ ### 3. Generate migration files
78
+
79
+ ```bash
80
+ rails db:generate_migration
81
+ ```
82
+
83
+ Migsupo compares the Schemafile against the current database and writes migration files to `db/migrate/`.
84
+
85
+ ### 4. Review and run migrations
86
+
87
+ ```bash
88
+ # Review the generated files
89
+ cat db/migrate/20260324120000_create_users.rb
90
+
91
+ # Apply to the database
92
+ rails db:migrate
93
+ ```
94
+
95
+ ## Commands
96
+
97
+ ### `rails db:generate_migration`
98
+
99
+ Generate migration files for all differences between the Schemafile and the current database.
100
+
101
+ ```bash
102
+ rails db:generate_migration
103
+ rails db:generate_migration SCHEMAFILE=db/Schemafile
104
+ rails db:generate_migration OUTPUT_DIR=db/migrate
105
+ rails db:generate_migration DRY_RUN=true # print to stdout, no files written
106
+ rails db:generate_migration VERBOSE=true # also print diff summary
107
+ ```
108
+
109
+ ### `rails db:generate_migration:diff`
110
+
111
+ Print a human-readable diff between the Schemafile and the current database. No files are written.
112
+
113
+ ```bash
114
+ rails db:generate_migration:diff
115
+ ```
116
+
117
+ ### `rails db:generate_migration:check`
118
+
119
+ Exit with code 1 if the Schemafile and the current database are not in sync. Useful in CI pipelines.
120
+
121
+ ```bash
122
+ rails db:generate_migration:check
123
+ ```
124
+
125
+ ## Environment Variables
126
+
127
+ | Variable | Default | Description |
128
+ |---|---|---|
129
+ | `SCHEMAFILE` | `Schemafile` | Path to the Schemafile |
130
+ | `OUTPUT_DIR` | `db/migrate` | Output directory for generated migration files |
131
+ | `LOADER` | `activerecord` | Schema loader: `activerecord` or `schema_rb` |
132
+ | `DRY_RUN` | `false` | Print migrations to stdout instead of writing files |
133
+ | `VERBOSE` | `false` | Print diff summary before generating files |
134
+
135
+ ### Loaders
136
+
137
+ - **`activerecord`** (default): Reads the current schema directly from the database via `ActiveRecord::Base.connection`. Always reflects the true current state.
138
+ - **`schema_rb`**: Reads from `db/schema.rb` without a live database connection. Useful for offline environments, but only as accurate as the last `rails db:migrate` run.
139
+
140
+ ## Configuration
141
+
142
+ You can configure Migsupo in an initializer:
143
+
144
+ ```ruby
145
+ # config/initializers/migsupo.rb
146
+ Migsupo.configure do |config|
147
+ config.schemafile_path = Rails.root.join("db/Schemafile")
148
+ config.migrations_dir = Rails.root.join("db/migrate")
149
+ config.ignored_tables = %w[schema_migrations ar_internal_metadata]
150
+ config.migration_version = "7.1" # defaults to current Rails version
151
+
152
+ # Explicit rename hints (see "Column Renames" below)
153
+ config.rename_hints = {
154
+ "users" => { "full_name" => "name" }
155
+ }
156
+ end
157
+ ```
158
+
159
+ ## Generated Migration Examples
160
+
161
+ ### New table
162
+
163
+ ```ruby
164
+ class CreateUsers < ActiveRecord::Migration[7.1]
165
+ def change
166
+ create_table :users do |t|
167
+ t.string :name, null: false
168
+ t.string :email, null: false
169
+ t.integer :age
170
+
171
+ t.timestamps
172
+ end
173
+
174
+ add_index :users, [:email], name: "index_users_on_email", unique: true
175
+ end
176
+ end
177
+ ```
178
+
179
+ ### Add columns
180
+
181
+ ```ruby
182
+ class AddColumnsToUsers < ActiveRecord::Migration[7.1]
183
+ def change
184
+ add_column :users, :phone, :string
185
+ add_index :users, [:phone], name: "index_users_on_phone"
186
+ end
187
+ end
188
+ ```
189
+
190
+ ### Change column type (uses explicit `up`/`down`)
191
+
192
+ ```ruby
193
+ class ModifyUsers < ActiveRecord::Migration[7.1]
194
+ def up
195
+ change_column :users, :age, :bigint
196
+ end
197
+
198
+ def down
199
+ change_column :users, :age, :integer
200
+ end
201
+ end
202
+ ```
203
+
204
+ ## Column Renames
205
+
206
+ Migsupo cannot automatically distinguish a rename from a drop + add, so rename detection is **opt-in** via `rename_hints`. Without a hint, Migsupo will emit `remove_column` + `add_column`, which would cause data loss.
207
+
208
+ ```ruby
209
+ # config/initializers/migsupo.rb
210
+ Migsupo.configure do |config|
211
+ config.rename_hints = {
212
+ "users" => { "full_name" => "name" }
213
+ }
214
+ end
215
+ ```
216
+
217
+ This generates `rename_column` instead of `remove_column` + `add_column`.
218
+
219
+ ## CI Integration
220
+
221
+ Use `db:generate_migration:check` to verify that your Schemafile and database are always in sync after all migrations have been applied:
222
+
223
+ ```yaml
224
+ # .github/workflows/ci.yml
225
+ - name: Check schema sync
226
+ run: bundle exec rails db:generate_migration:check
227
+ ```
228
+
229
+ ## Comparison with ridgepole
230
+
231
+ | | ridgepole | migsupo |
232
+ |---|---|---|
233
+ | Schema definition | Schemafile | Schemafile (compatible) |
234
+ | How changes are applied | Directly to DB | Generates Rails migration files |
235
+ | Rails migration workflow | Bypassed | Preserved |
236
+ | Rollback support | No | Yes (via `rails db:rollback`) |
237
+ | Review before applying | Not built-in | Yes (review generated files) |
238
+
239
+ ## Requirements
240
+
241
+ - Ruby >= 3.0
242
+ - Rails >= 6.1
243
+
244
+ ## License
245
+
246
+ MIT
@@ -0,0 +1,19 @@
1
+ module Migsupo
2
+ class Configuration
3
+ attr_accessor :schemafile_path
4
+ attr_accessor :migrations_dir
5
+ attr_accessor :loader
6
+ attr_accessor :ignored_tables
7
+ attr_accessor :rename_hints
8
+ attr_accessor :migration_version
9
+
10
+ def initialize
11
+ @schemafile_path = "Schemafile"
12
+ @migrations_dir = "db/migrate"
13
+ @loader = :active_record
14
+ @ignored_tables = %w[schema_migrations ar_internal_metadata]
15
+ @rename_hints = {}
16
+ @migration_version = nil
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ module Migsupo
2
+ module Differ
3
+ class Diff
4
+ attr_reader :operations
5
+
6
+ def initialize(operations: [])
7
+ @operations = operations.freeze
8
+ freeze
9
+ end
10
+
11
+ def empty?
12
+ @operations.empty?
13
+ end
14
+
15
+ def to_s
16
+ return "No changes." if empty?
17
+
18
+ @operations.map(&:to_s).join("\n")
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,123 @@
1
+ require "set"
2
+ require_relative "diff"
3
+ require_relative "operations/create_table"
4
+ require_relative "operations/drop_table"
5
+ require_relative "operations/add_column"
6
+ require_relative "operations/remove_column"
7
+ require_relative "operations/change_column"
8
+ require_relative "operations/rename_column"
9
+ require_relative "operations/add_index"
10
+ require_relative "operations/remove_index"
11
+
12
+ module Migsupo
13
+ module Differ
14
+ class DiffCalculator
15
+ def initialize(rename_hints: {})
16
+ @rename_hints = rename_hints
17
+ end
18
+
19
+ def calculate(desired:, current:)
20
+ operations = []
21
+
22
+ desired_names = Set.new(desired.tables.keys)
23
+ current_names = Set.new(current.tables.keys)
24
+
25
+ (desired_names - current_names).each do |name|
26
+ operations << Operations::CreateTable.new(desired.tables[name])
27
+ end
28
+
29
+ (current_names - desired_names).each do |name|
30
+ operations << Operations::DropTable.new(current.tables[name])
31
+ end
32
+
33
+ (desired_names & current_names).each do |name|
34
+ operations.concat(diff_table(desired.tables[name], current.tables[name]))
35
+ end
36
+
37
+ Diff.new(operations: operations)
38
+ end
39
+
40
+ private
41
+
42
+ def diff_table(desired, current)
43
+ operations = []
44
+ operations.concat(diff_columns(desired, current))
45
+ operations.concat(diff_indexes(desired, current))
46
+ operations
47
+ end
48
+
49
+ def diff_columns(desired, current)
50
+ desired_cols = desired.columns.each_with_object({}) { |c, h| h[c.name] = c }
51
+ current_cols = current.columns.each_with_object({}) { |c, h| h[c.name] = c }
52
+
53
+ added = desired_cols.keys - current_cols.keys
54
+ removed = current_cols.keys - desired_cols.keys
55
+
56
+ hints = @rename_hints[desired.name] || {}
57
+ operations = apply_rename_hints(desired.name, hints, added, removed, desired_cols, current_cols)
58
+
59
+ # Remaining added/removed after renames
60
+ renamed_old = operations.select { |op| op.is_a?(Operations::RenameColumn) }.map(&:old_name)
61
+ renamed_new = operations.select { |op| op.is_a?(Operations::RenameColumn) }.map(&:new_name)
62
+
63
+ (added - renamed_new).each do |name|
64
+ operations << Operations::AddColumn.new(table_name: desired.name, column: desired_cols[name])
65
+ end
66
+
67
+ (removed - renamed_old).each do |name|
68
+ operations << Operations::RemoveColumn.new(table_name: desired.name, column: current_cols[name])
69
+ end
70
+
71
+ (desired_cols.keys & current_cols.keys).each do |name|
72
+ next if desired_cols[name] == current_cols[name]
73
+
74
+ operations << Operations::ChangeColumn.new(
75
+ table_name: desired.name,
76
+ new_column: desired_cols[name],
77
+ old_column: current_cols[name]
78
+ )
79
+ end
80
+
81
+ operations
82
+ end
83
+
84
+ def apply_rename_hints(table_name, hints, added, removed, desired_cols, current_cols)
85
+ operations = []
86
+ hints.each do |old_name, new_name|
87
+ next unless removed.include?(old_name) && added.include?(new_name)
88
+
89
+ operations << Operations::RenameColumn.new(
90
+ table_name: table_name,
91
+ old_name: old_name,
92
+ new_name: new_name
93
+ )
94
+ end
95
+ operations
96
+ end
97
+
98
+ def diff_indexes(desired, current)
99
+ desired_idxs = desired.indexes.each_with_object({}) { |i, h| h[i.name] = i }
100
+ current_idxs = current.indexes.each_with_object({}) { |i, h| h[i.name] = i }
101
+
102
+ operations = []
103
+
104
+ (desired_idxs.keys - current_idxs.keys).each do |name|
105
+ operations << Operations::AddIndex.new(table_name: desired.name, index: desired_idxs[name])
106
+ end
107
+
108
+ (current_idxs.keys - desired_idxs.keys).each do |name|
109
+ operations << Operations::RemoveIndex.new(table_name: desired.name, index: current_idxs[name])
110
+ end
111
+
112
+ (desired_idxs.keys & current_idxs.keys).each do |name|
113
+ next if desired_idxs[name] == current_idxs[name]
114
+
115
+ operations << Operations::RemoveIndex.new(table_name: desired.name, index: current_idxs[name])
116
+ operations << Operations::AddIndex.new(table_name: desired.name, index: desired_idxs[name])
117
+ end
118
+
119
+ operations
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,27 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class AddColumn
5
+ attr_reader :table_name, :column
6
+
7
+ def initialize(table_name:, column:)
8
+ @table_name = table_name.to_s
9
+ @column = column
10
+ freeze
11
+ end
12
+
13
+ def migration_type
14
+ :add_column
15
+ end
16
+
17
+ def reversible?
18
+ true
19
+ end
20
+
21
+ def to_s
22
+ "add_column #{table_name}.#{column.name} (#{column.type})"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class AddIndex
5
+ attr_reader :table_name, :index
6
+
7
+ def initialize(table_name:, index:)
8
+ @table_name = table_name.to_s
9
+ @index = index
10
+ freeze
11
+ end
12
+
13
+ def migration_type
14
+ :add_index
15
+ end
16
+
17
+ def reversible?
18
+ true
19
+ end
20
+
21
+ def to_s
22
+ "add_index #{table_name} [#{index.columns.join(', ')}]"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class ChangeColumn
5
+ attr_reader :table_name, :new_column, :old_column
6
+
7
+ def initialize(table_name:, new_column:, old_column:)
8
+ @table_name = table_name.to_s
9
+ @new_column = new_column
10
+ @old_column = old_column
11
+ freeze
12
+ end
13
+
14
+ def column_name
15
+ @new_column.name
16
+ end
17
+
18
+ def migration_type
19
+ :change_column
20
+ end
21
+
22
+ # change_column is irreversible in Rails unless we provide explicit up/down
23
+ def reversible?
24
+ false
25
+ end
26
+
27
+ def to_s
28
+ "change_column #{table_name}.#{column_name} (#{old_column.type} -> #{new_column.type})"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class CreateTable
5
+ attr_reader :table
6
+
7
+ def initialize(table)
8
+ @table = table
9
+ freeze
10
+ end
11
+
12
+ def table_name
13
+ @table.name
14
+ end
15
+
16
+ def migration_type
17
+ :create_table
18
+ end
19
+
20
+ def reversible?
21
+ true
22
+ end
23
+
24
+ def to_s
25
+ "create_table #{table_name}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class DropTable
5
+ attr_reader :table
6
+
7
+ def initialize(table)
8
+ @table = table
9
+ freeze
10
+ end
11
+
12
+ def table_name
13
+ @table.name
14
+ end
15
+
16
+ def migration_type
17
+ :drop_table
18
+ end
19
+
20
+ def reversible?
21
+ true
22
+ end
23
+
24
+ def to_s
25
+ "drop_table #{table_name}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class RemoveColumn
5
+ attr_reader :table_name, :column
6
+
7
+ def initialize(table_name:, column:)
8
+ @table_name = table_name.to_s
9
+ @column = column
10
+ freeze
11
+ end
12
+
13
+ def column_name
14
+ @column.name
15
+ end
16
+
17
+ def migration_type
18
+ :remove_column
19
+ end
20
+
21
+ def reversible?
22
+ true
23
+ end
24
+
25
+ def to_s
26
+ "remove_column #{table_name}.#{column_name}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class RemoveIndex
5
+ attr_reader :table_name, :index
6
+
7
+ def initialize(table_name:, index:)
8
+ @table_name = table_name.to_s
9
+ @index = index
10
+ freeze
11
+ end
12
+
13
+ def migration_type
14
+ :remove_index
15
+ end
16
+
17
+ def reversible?
18
+ true
19
+ end
20
+
21
+ def to_s
22
+ "remove_index #{table_name} [#{index.columns.join(', ')}]"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ module Migsupo
2
+ module Differ
3
+ module Operations
4
+ class RenameColumn
5
+ attr_reader :table_name, :old_name, :new_name
6
+
7
+ def initialize(table_name:, old_name:, new_name:)
8
+ @table_name = table_name.to_s
9
+ @old_name = old_name.to_s
10
+ @new_name = new_name.to_s
11
+ freeze
12
+ end
13
+
14
+ def migration_type
15
+ :rename_column
16
+ end
17
+
18
+ def reversible?
19
+ true
20
+ end
21
+
22
+ def to_s
23
+ "rename_column #{table_name}.#{old_name} -> #{new_name}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end