actual_db_schema 0.9.2 → 0.10.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: 026a44cc5c2748387f2d5b62796b6bde8fbed503ab4689bc46e294c8bd2a6fba
4
- data.tar.gz: faae3b9dcc2f31b5167f2fb6e7fa62f65135ceccd60b0f9dea12f8f7a293049d
3
+ metadata.gz: f52b65d1b46f79d260c1e2d8c6fa768e03d991220954a88d7ff9f3c9f9e31a7b
4
+ data.tar.gz: c50d05e0800fd85c35cc84f570342e67af2c5212c1629cb93facfcde2dd6b749
5
5
  SHA512:
6
- metadata.gz: e38622b499f30c7fc02f8a57c77e59e41412529299cc67c9827b7c52959ee9bd516bc85fc485de6c0abbd818b33c7c4eed14820f41b6a92ba58f339364494cef
7
- data.tar.gz: 238b844491e2b78d2ff9454e065101647cd5e55bab11101739c19cb77f7c015c1316d511baaa22bfb8d611e5f4cd240c01093b6f5529ec451d612219ab7a14dc
6
+ metadata.gz: 3d4af8729461e9c106215a72d78d22628217e1c1138307a39056fcd02e567f656ca6452b5eee20a82e886b141c11c35ce8eff51785758661cc3daa59d8fef259
7
+ data.tar.gz: 5ca66e12fdb0969b1a8296b945231286908069e789d6b7329fd84f3459a4d2c2d502621676cf220fade2bc628f1c5cf8c35cae8a8a101d5e99b2f711a63a0764
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
+ ## [0.10.0] - 2026-10-11
2
+
3
+ - Remove the `parser` dependency and use `prism` directly.
4
+ - Update the README and gem description to explain database synchronization, migration management, and schema diff troubleshooting.
5
+ - Add an animated demo to the README; exclude demo media from the gem package.
6
+
1
7
  ## [0.9.2] - 2026-06-14
2
8
 
3
9
  - Skip pending migrations in the UI
10
+ - Restore original connection after iterating through connections
4
11
 
5
12
  ## [0.9.1] - 2026-02-25
6
13
 
data/Gemfile.lock CHANGED
@@ -1,12 +1,10 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- actual_db_schema (0.9.2)
4
+ actual_db_schema (0.10.0)
5
5
  activerecord
6
6
  activesupport
7
- ast
8
7
  csv
9
- parser
10
8
  prism
11
9
 
12
10
  GEM
@@ -96,7 +94,7 @@ GEM
96
94
  concurrent-ruby (1.2.2)
97
95
  connection_pool (2.4.1)
98
96
  crass (1.0.6)
99
- csv (3.3.5)
97
+ csv (3.3.6)
100
98
  date (3.3.3)
101
99
  debug (1.8.0)
102
100
  irb (>= 1.5.0)
data/README.md CHANGED
@@ -1,46 +1,91 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/actual_db_schema.svg)](https://badge.fury.io/rb/actual_db_schema)
2
2
 
3
- # ActualDbSchema
3
+ # ActualDbSchema: Keep Rails Databases in Sync with Git Branches
4
4
 
5
- **Stop database headaches when switching Git branches in Rails**
5
+ **Manage stale migrations, fix database drift, and understand schema changes in Rails.**
6
6
 
7
- Keep your database schema perfectly synchronized across Git branches, eliminate broken tests and schema conflicts, and save wasted hours on phantom migrations.
7
+ `actual_db_schema` keeps your database aligned with the migrations on your current Git branch. It tracks executed migration source code and rolls back **phantom migrations** left behind by other branches. It also provides a web dashboard, annotated schema diffs, migration history, and manual cleanup tools for development, staging, and sandbox environments.
8
8
 
9
- ## 🚀 What You Get
9
+ ## Problems ActualDbSchema Solves
10
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
11
+ ### Database errors and failing tests after switching branches
18
12
 
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" />
13
+ Switching Git branches changes your code and migration files, but does not undo migrations already applied to your database. A column, table, index, or constraint from another branch can break the application or its tests. For example, an extra `NOT NULL` column can cause `ActiveRecord::NotNullViolation` when the current branch inserts a record.
20
14
 
21
- And you get all of that with **zero** changes to your workflow!
15
+ ActualDbSchema rolls back tracked migrations missing from the current branch, removing their database changes when their reversals succeed.
22
16
 
23
- ## 🎯 The Problem This Solves
17
+ ### Manual rollback work when moving between feature branches
24
18
 
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
19
+ Without a stored copy of a migration, you may need to find the branch that introduced it, check it out, roll it back, and return to your work. These stale migrations are called **phantom migrations**: they have been applied to the database, but their files are absent from the current branch.
31
20
 
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
21
+ The gem saves migration source code and branch metadata so it can identify and roll back phantom migrations without checking out their original branch. Cleanup runs as part of the schema dump triggered by `rails db:migrate`. An optional [post-checkout Git hook](#automatic-phantom-migration-rollback-on-branch-switch) runs cleanup when you switch branches. You can also inspect and roll back migrations manually through rake tasks or the web UI.
22
+
23
+ ### Unwanted schema.rb diffs and schema conflicts in Git
24
+
25
+ **Why does `schema.rb` keep changing after `db:migrate`, even when you have not changed a migration?** Rails dumps your database's actual structure, including leftover tables, columns, and indexes from other branches. Those changes pollute `db/schema.rb` or `db/structure.sql` and can lead to unrelated pull request changes and Git merge conflicts.
26
+
27
+ ActualDbSchema rolls back tracked phantom migrations before the schema dump, preventing their leftover changes from appearing in the generated file. Legitimate schema changes from the current branch still appear in the diff. Both schema formats are supported.
28
+
29
+ ### Rebuilding the entire database to clean up stale migrations
30
+
31
+ Resetting a development database and replaying migrations can take time and discard local data. ActualDbSchema targets tracked phantom migrations instead of requiring a full database reset. Each rollback still executes the migration's reversal and can remove data in affected tables or columns.
32
+
33
+ ### Difficulty tracing a schema change to its migration
34
+
35
+ When a schema diff contains unexpected changes, it can be hard to tell which migration introduced them. The [annotated schema diff](#schema-diff-with-migration-annotations) associates changes with migration source files, available through a rake task or the web UI. This helps you investigate a diff before deciding how to resolve it.
36
+
37
+ ### Missing visibility into migration history and database state
38
+
39
+ The [web dashboard](#-web-interface) lets you inspect migration status, source code, branch metadata, and phantom migrations across configured databases. It supports individual migration actions and remains accessible when pending migrations would otherwise block Rails requests.
40
+
41
+ ### Orphaned migration versions with missing source files
42
+
43
+ Your database can contain migration version records whose source files are no longer available. The [broken migration cleanup tools](#delete-broken-migrations) identify and delete these orphaned records through the UI or a rake task. Deleting a version record does **not** reverse its database changes; use phantom migration rollback when stored source code is available.
44
+
45
+ ### Database drift across multiple databases, tenants, and shared environments
46
+
47
+ Branch-specific migrations can leave several databases or tenant schemas out of sync, not just the primary development database. ActualDbSchema processes configured databases, supports a configurable list of [tenant schemas](#multi-tenancy-support), and lets you [exclude infrastructure databases](#excluding-databases-from-processing).
48
+
49
+ For staging or sandbox environments, optional [database-backed migration storage](#3-store-migrations-in-the-database) retains executed migration sources in the database rather than relying on one machine's local files. Features can be enabled per environment.
50
+
51
+ ### Friction when experimenting with schema changes in the Rails console
52
+
53
+ The optional [console migrations](#console-migrations) feature exposes migration methods such as `add_column`, `add_index`, and `create_table` directly in the Rails console. This helps with local schema experiments and manual repairs; it does not create a migration file for you.
54
+
55
+ ## Example: Switch Branches Without Leaving Phantom Migrations Behind
56
+
57
+ Suppose a feature branch adds a required `users.nickname` column. After switching to `main`, which has no such migration, inserts may fail because the application does not populate that column. The next schema dump can also add the unrelated column to `schema.rb`.
58
+
59
+ With `actual_db_schema` installed ([Quick Start](#-quick-start)), keep using your normal workflow:
60
+
61
+ ```sh
62
+ git switch main
63
+ bundle exec rails db:migrate
64
+ ```
65
+
66
+ The gem uses its stored migration source to roll back the phantom migration before Rails dumps the schema. When the rollback succeeds, the extra column is removed, fixing both the stale constraint and its unwanted schema diff.
67
+
68
+ ## Scope and Rollback Requirements
69
+
70
+ The gem needs a stored copy of a migration to roll it back, so install it before running migrations you want it to track. Irreversible migrations and rollbacks that depend on unavailable branch code require manual intervention; failed rollbacks are reported. Phantom migrations are rolled back in reverse migration order.
71
+
72
+ ActualDbSchema does not detect edits to an already-applied migration that remains on the current branch. Its schema cleanup targets phantom migrations; it does not sort schema columns or resolve every Git merge conflict.
73
+
74
+ ## Animated Demo
75
+
76
+ [![ActualDbSchema animated demo](docs/actual_db_schema_demo.gif)](docs/actual_db_schema_h264.mp4)
77
+
78
+ [Watch the full-resolution video](docs/actual_db_schema_h264.mp4)
79
+
80
+ <img width="3024" height="1886" alt="ActualDbSchema Rails migration dashboard showing migration history and database management" src="https://github.com/user-attachments/assets/87cfb7b4-6380-4dad-ab18-6a0633f561b5" />
36
81
 
37
82
  ## 🌟 Complete Feature Set
38
83
 
39
84
  ### Core Migration Management
40
85
  - **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
86
+ - **Smart Rollback**: Rolls back phantom migrations in reverse migration order
87
+ - **Rollback Failure Reporting**: Reports irreversible migrations and other rollback failures for manual resolution
88
+ - **Multi-Database Support**: Processes multiple database configurations with configurable exclusions
44
89
  - **Schema Format Agnostic**: Supports both `schema.rb` and `structure.sql`
45
90
 
46
91
  ### Automation & Git Integration
@@ -65,8 +110,8 @@ And you get all of that with **zero** changes to your workflow!
65
110
  ### Team & Environment Support
66
111
  - **Multi-Tenant Compatible**: Works with apartment gem and similar multi-tenant setups
67
112
  - **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
113
+ - **Shared Migration Storage**: Optional database storage retains migration sources across environments
114
+ - **Rollback Instrumentation**: Emits notifications for successful rollbacks and provides optional persisted statistics
70
115
 
71
116
  ### Manual Control Options
72
117
  - **Manual Rollback Mode**: Disable automatic rollback for full manual control
@@ -95,11 +140,11 @@ That's it! Now just run `rails db:migrate` as usual - phantom migrations roll ba
95
140
 
96
141
  ## 🔧 How It Works
97
142
 
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*.
143
+ This gem stores migrations executed while it is installed, including their source code, in the `tmp/migrated` folder by default. [Database storage](#3-store-migrations-in-the-database) is also available. Before the Rails `db:schema:dump` task runs, it attempts to roll back the *phantom migrations*.
99
144
 
100
145
  The *phantom migrations* list is the difference between the migrations you've executed (in the `tmp/migrated` folder) and the current ones (in the `db/migrate` folder).
101
146
 
102
- Therefore, all you do is run rails `db:migrate` in your current branch. `actual_db_schema` will ensure the DB schema is up-to-date. You'll never have an inaccurate `schema.rb` file again.
147
+ Run `rails db:migrate` in your current branch as usual. With automatic rollback enabled (the default), `actual_db_schema` rolls back tracked phantom migrations in reverse migration order before the schema dump, removing their leftover schema changes when the rollbacks succeed.
103
148
 
104
149
  ## Installation
105
150
 
@@ -8,11 +8,10 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Andrei Kaleshka"]
9
9
  spec.email = ["ka8725@gmail.com"]
10
10
 
11
- spec.summary = "Keep DB schema in sync across branches effortlessly."
11
+ spec.summary = "Keep Rails databases in sync across Git branches."
12
12
  spec.description = <<~DESC
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.
13
+ Keep Rails databases in sync across Git branches. Automatically roll back stale migrations,
14
+ prevent database errors and unwanted schema diffs, and inspect migration history with a web dashboard.
16
15
  DESC
17
16
  spec.homepage = "https://blog.widefix.com/actual-db-schema/"
18
17
  spec.license = "MIT"
@@ -28,7 +27,8 @@ Gem::Specification.new do |spec|
28
27
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
29
28
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
30
29
  `git ls-files -z`.split("\x0").reject do |f|
31
- (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
30
+ (f == __FILE__) || %w[docs/actual_db_schema_h264.mp4 docs/actual_db_schema_demo.gif].include?(f) ||
31
+ f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
32
32
  end
33
33
  end
34
34
  spec.bindir = "exe"
@@ -38,9 +38,7 @@ Gem::Specification.new do |spec|
38
38
  # Uncomment to register a new dependency of your gem
39
39
  spec.add_runtime_dependency "activerecord"
40
40
  spec.add_runtime_dependency "activesupport"
41
- spec.add_runtime_dependency "ast"
42
41
  spec.add_runtime_dependency "csv"
43
- spec.add_runtime_dependency "parser"
44
42
  spec.add_runtime_dependency "prism"
45
43
 
46
44
  spec.add_development_dependency "appraisal"
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ast"
4
3
  require "prism"
5
4
 
6
5
  module ActualDbSchema
@@ -8,18 +7,6 @@ module ActualDbSchema
8
7
  module MigrationParser
9
8
  extend self
10
9
 
11
- PARSER_MAPPING = {
12
- add_column: ->(args) { parse_add_column(args) },
13
- change_column: ->(args) { parse_change_column(args) },
14
- remove_column: ->(args) { parse_remove_column(args) },
15
- rename_column: ->(args) { parse_rename_column(args) },
16
- add_index: ->(args) { parse_add_index(args) },
17
- remove_index: ->(args) { parse_remove_index(args) },
18
- rename_index: ->(args) { parse_rename_index(args) },
19
- create_table: ->(args) { parse_create_table(args) },
20
- drop_table: ->(args) { parse_drop_table(args) }
21
- }.freeze
22
-
23
10
  def parse_all_migrations(dirs)
24
11
  changes_by_path = {}
25
12
  handled_files = Set.new
@@ -41,221 +28,233 @@ module ActualDbSchema
41
28
  private
42
29
 
43
30
  def parse_file(file_path)
44
- Prism::Translation::Parser.parse_file(file_path)
31
+ parse_result = Prism.parse_file(file_path)
32
+ raise SyntaxError, "Migration #{file_path} is syntax invalid" unless parse_result.success?
33
+
34
+ parse_result.value
45
35
  end
46
36
 
47
37
  def find_migration_changes(node)
48
- return [] unless node.is_a?(Parser::AST::Node)
49
-
50
- changes = []
51
- if node.type == :block
52
- return process_block_node(node)
53
- elsif node.type == :send
54
- changes.concat(process_send_node(node))
55
- end
56
-
57
- node.children.each { |child| changes.concat(find_migration_changes(child)) if child.is_a?(Parser::AST::Node) }
58
-
59
- changes
38
+ visitor = MigrationVisitor.new
39
+ visitor.visit(node)
40
+ visitor.changes
60
41
  end
61
42
 
62
- def process_block_node(node)
63
- changes = []
64
- send_node = node.children.first
65
- return changes unless send_node.type == :send
43
+ # Internal class used to process the AST and collect migration information.
44
+ class MigrationVisitor < Prism::Visitor
45
+ MAPPING = {
46
+ add_column: ->(visitor, args) { visitor.parse_add_column(args) },
47
+ change_column: ->(visitor, args) { visitor.parse_change_column(args) },
48
+ remove_column: ->(visitor, args) { visitor.parse_remove_column(args) },
49
+ rename_column: ->(visitor, args) { visitor.parse_rename_column(args) },
50
+ add_index: ->(visitor, args) { visitor.parse_add_index(args) },
51
+ remove_index: ->(visitor, args) { visitor.parse_remove_index(args) },
52
+ rename_index: ->(visitor, args) { visitor.parse_rename_index(args) },
53
+ create_table: ->(visitor, args) { visitor.parse_create_table(args) },
54
+ drop_table: ->(visitor, args) { visitor.parse_drop_table(args) }
55
+ }.freeze
56
+
57
+ attr_reader :changes
58
+
59
+ def initialize
60
+ super
61
+ @changes = []
62
+ end
66
63
 
67
- method_name = send_node.children[1]
68
- return changes unless method_name == :create_table
64
+ def visit_call_node(node)
65
+ arguments = node.arguments&.arguments
66
+ if node.block
67
+ return unless node.name == :create_table
69
68
 
70
- change = parse_create_table_with_block(send_node, node)
71
- changes << change if change
72
- changes
73
- end
69
+ change = parse_create_table_with_block(arguments, node.block)
70
+ @changes << change if change
71
+ elsif arguments && (parser = MAPPING[node.name])
72
+ change = parser.call(self, arguments)
73
+ @changes << change if change
74
74
 
75
- def process_send_node(node)
76
- changes = []
77
- _receiver, method_name, *args = node.children
78
- if (parser = PARSER_MAPPING[method_name])
79
- change = parser.call(args)
80
- changes << change if change
75
+ return
76
+ end
77
+ super
81
78
  end
82
79
 
83
- changes
84
- end
80
+ def parse_add_column(args)
81
+ return unless args.size >= 3
85
82
 
86
- def parse_add_column(args)
87
- return unless args.size >= 3
83
+ {
84
+ action: :add_column,
85
+ table: sym_value(args[0]),
86
+ column: sym_value(args[1]),
87
+ type: sym_value(args[2]),
88
+ options: parse_hash(args[3])
89
+ }
90
+ end
88
91
 
89
- {
90
- action: :add_column,
91
- table: sym_value(args[0]),
92
- column: sym_value(args[1]),
93
- type: sym_value(args[2]),
94
- options: parse_hash(args[3])
95
- }
96
- end
92
+ def parse_change_column(args)
93
+ return unless args.size >= 3
97
94
 
98
- def parse_change_column(args)
99
- return unless args.size >= 3
95
+ {
96
+ action: :change_column,
97
+ table: sym_value(args[0]),
98
+ column: sym_value(args[1]),
99
+ type: sym_value(args[2]),
100
+ options: parse_hash(args[3])
101
+ }
102
+ end
100
103
 
101
- {
102
- action: :change_column,
103
- table: sym_value(args[0]),
104
- column: sym_value(args[1]),
105
- type: sym_value(args[2]),
106
- options: parse_hash(args[3])
107
- }
108
- end
104
+ def parse_remove_column(args)
105
+ return unless args.size >= 2
109
106
 
110
- def parse_remove_column(args)
111
- return unless args.size >= 2
107
+ {
108
+ action: :remove_column,
109
+ table: sym_value(args[0]),
110
+ column: sym_value(args[1]),
111
+ options: parse_hash(args[2])
112
+ }
113
+ end
112
114
 
113
- {
114
- action: :remove_column,
115
- table: sym_value(args[0]),
116
- column: sym_value(args[1]),
117
- options: parse_hash(args[2])
118
- }
119
- end
115
+ def parse_rename_column(args)
116
+ return unless args.size >= 3
120
117
 
121
- def parse_rename_column(args)
122
- return unless args.size >= 3
118
+ {
119
+ action: :rename_column,
120
+ table: sym_value(args[0]),
121
+ old_column: sym_value(args[1]),
122
+ new_column: sym_value(args[2])
123
+ }
124
+ end
123
125
 
124
- {
125
- action: :rename_column,
126
- table: sym_value(args[0]),
127
- old_column: sym_value(args[1]),
128
- new_column: sym_value(args[2])
129
- }
130
- end
126
+ def parse_add_index(args)
127
+ return unless args.size >= 2
131
128
 
132
- def parse_add_index(args)
133
- return unless args.size >= 2
129
+ {
130
+ action: :add_index,
131
+ table: sym_value(args[0]),
132
+ columns: array_or_single_value(args[1]),
133
+ options: parse_hash(args[2])
134
+ }
135
+ end
134
136
 
135
- {
136
- action: :add_index,
137
- table: sym_value(args[0]),
138
- columns: array_or_single_value(args[1]),
139
- options: parse_hash(args[2])
140
- }
141
- end
137
+ def parse_remove_index(args)
138
+ return unless args.size >= 1
142
139
 
143
- def parse_remove_index(args)
144
- return unless args.size >= 1
140
+ {
141
+ action: :remove_index,
142
+ table: sym_value(args[0]),
143
+ options: parse_hash(args[1])
144
+ }
145
+ end
145
146
 
146
- {
147
- action: :remove_index,
148
- table: sym_value(args[0]),
149
- options: parse_hash(args[1])
150
- }
151
- end
147
+ def parse_rename_index(args)
148
+ return unless args.size >= 3
152
149
 
153
- def parse_rename_index(args)
154
- return unless args.size >= 3
150
+ {
151
+ action: :rename_index,
152
+ table: sym_value(args[0]),
153
+ old_name: node_value(args[1]),
154
+ new_name: node_value(args[2])
155
+ }
156
+ end
155
157
 
156
- {
157
- action: :rename_index,
158
- table: sym_value(args[0]),
159
- old_name: node_value(args[1]),
160
- new_name: node_value(args[2])
161
- }
162
- end
158
+ def parse_create_table(args)
159
+ return unless args.size >= 1
163
160
 
164
- def parse_create_table(args)
165
- return unless args.size >= 1
161
+ {
162
+ action: :create_table,
163
+ table: sym_value(args[0]),
164
+ options: parse_hash(args[1])
165
+ }
166
+ end
166
167
 
167
- {
168
- action: :create_table,
169
- table: sym_value(args[0]),
170
- options: parse_hash(args[1])
171
- }
172
- end
168
+ def parse_drop_table(args)
169
+ return unless args.size >= 1
173
170
 
174
- def parse_drop_table(args)
175
- return unless args.size >= 1
171
+ {
172
+ action: :drop_table,
173
+ table: sym_value(args[0]),
174
+ options: parse_hash(args[1])
175
+ }
176
+ end
176
177
 
177
- {
178
- action: :drop_table,
179
- table: sym_value(args[0]),
180
- options: parse_hash(args[1])
181
- }
182
- end
178
+ def parse_create_table_with_block(args, block_node)
179
+ columns = parse_create_table_columns(block_node.body)
180
+ {
181
+ action: :create_table,
182
+ table: sym_value(args[0]),
183
+ options: parse_hash(args[1]),
184
+ columns: columns
185
+ }
186
+ end
183
187
 
184
- def parse_create_table_with_block(send_node, block_node)
185
- args = send_node.children[2..]
186
- columns = parse_create_table_columns(block_node.children[2])
187
- {
188
- action: :create_table,
189
- table: sym_value(args[0]),
190
- options: parse_hash(args[1]),
191
- columns: columns
192
- }
193
- end
188
+ def parse_create_table_columns(statements_node)
189
+ return [] unless statements_node
194
190
 
195
- def parse_create_table_columns(body_node)
196
- return [] unless body_node
191
+ statements_node.body.map { |node| parse_column_node(node) }.compact
192
+ end
197
193
 
198
- nodes = body_node.type == :begin ? body_node.children : [body_node]
199
- nodes.map { |node| parse_column_node(node) }.compact
200
- end
194
+ def parse_column_node(node)
195
+ return unless node.is_a?(Prism::CallNode)
201
196
 
202
- def parse_column_node(node)
203
- return unless node.is_a?(Parser::AST::Node) && node.type == :send
197
+ method = node.name
198
+ return parse_timestamps if method == :timestamps
204
199
 
205
- method = node.children[1]
206
- return parse_timestamps if method == :timestamps
200
+ arguments = node.arguments&.arguments
201
+ return unless arguments
207
202
 
208
- {
209
- column: sym_value(node.children[2]),
210
- type: method,
211
- options: parse_hash(node.children[3])
212
- }
213
- end
203
+ {
204
+ column: sym_value(arguments[0]),
205
+ type: method,
206
+ options: parse_hash(arguments[1])
207
+ }
208
+ end
214
209
 
215
- def parse_timestamps
216
- [
217
- { column: :created_at, type: :datetime, options: { null: false } },
218
- { column: :updated_at, type: :datetime, options: { null: false } }
219
- ]
220
- end
210
+ def parse_timestamps
211
+ [
212
+ { column: :created_at, type: :datetime, options: { null: false } },
213
+ { column: :updated_at, type: :datetime, options: { null: false } }
214
+ ]
215
+ end
221
216
 
222
- def sym_value(node)
223
- return nil unless node && node.type == :sym
217
+ def sym_value(node)
218
+ return nil unless node.is_a?(Prism::SymbolNode)
224
219
 
225
- node.children.first
226
- end
220
+ node.value.to_sym
221
+ end
227
222
 
228
- def array_or_single_value(node)
229
- return [] unless node
223
+ def array_or_single_value(node)
224
+ return [] unless node
230
225
 
231
- if node.type == :array
232
- node.children.map { |child| node_value(child) }
233
- else
234
- node_value(node)
226
+ if node.is_a?(Prism::ArrayNode)
227
+ node.elements.map { |child| node_value(child) }
228
+ else
229
+ node_value(node)
230
+ end
235
231
  end
236
- end
237
232
 
238
- def parse_hash(node)
239
- return {} unless node && node.type == :hash
233
+ def parse_hash(node)
234
+ return {} unless node.is_a?(Prism::KeywordHashNode)
240
235
 
241
- node.children.each_with_object({}) do |pair_node, result|
242
- key_node, value_node = pair_node.children
243
- key = sym_value(key_node) || node_value(key_node)
244
- value = node_value(value_node)
245
- result[key] = value
236
+ node.elements.each_with_object({}) do |assoc_node, result|
237
+ key_node = assoc_node.key
238
+ value_node = assoc_node.value
239
+ key = sym_value(key_node) || node_value(key_node)
240
+ value = node_value(value_node)
241
+ result[key] = value
242
+ end
246
243
  end
247
- end
248
244
 
249
- def node_value(node)
250
- return nil unless node
251
-
252
- case node.type
253
- when :str, :sym, :int then node.children.first
254
- when true then true
255
- when false then false
256
- when nil then nil
257
- else
258
- node.children.first
245
+ def node_value(node)
246
+ return nil unless node
247
+
248
+ case node
249
+ when Prism::StringNode then node.content
250
+ when Prism::SymbolNode then node.value.to_sym
251
+ when Prism::IntegerNode then node.value
252
+ when Prism::TrueNode then true
253
+ when Prism::FalseNode then false
254
+ when Prism::NilNode then nil
255
+ else
256
+ node
257
+ end
259
258
  end
260
259
  end
261
260
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parser/ast/processor"
4
3
  require "prism"
5
4
 
6
5
  module ActualDbSchema
@@ -9,141 +8,121 @@ module ActualDbSchema
9
8
  module_function
10
9
 
11
10
  def parse_string(schema_content)
12
- ast = Prism::Translation::Parser.parse(schema_content)
11
+ parse_result = Prism.parse(schema_content)
12
+ raise SyntaxError, "Schema is syntax invalid" unless parse_result.success?
13
13
 
14
- collector = SchemaCollector.new
15
- collector.process(ast)
16
- collector.schema
14
+ visitor = SchemaVisitor.new
15
+ visitor.visit(parse_result.value)
16
+ visitor.schema
17
17
  end
18
18
 
19
- # Internal class used to process the AST and collect schema information.
20
- class SchemaCollector < Parser::AST::Processor
21
- attr_reader :schema
19
+ # Internal class used to visit a create_table block
20
+ class CreateTableVisitor < Prism::Visitor
21
+ attr_reader :columns
22
22
 
23
- def initialize
23
+ def initialize(block_arg)
24
24
  super()
25
- @schema = {}
25
+ @block_arg = block_arg
26
+ @columns = {}
26
27
  end
27
28
 
28
- def on_block(node)
29
- send_node, _args_node, body = *node
29
+ def visit_call_node(node)
30
+ return unless node.receiver.is_a?(Prism::LocalVariableReadNode) && node.receiver.name == @block_arg
30
31
 
31
- if create_table_call?(send_node)
32
- table_name = extract_table_name(send_node)
33
- columns = extract_columns(body)
34
- @schema[table_name] = columns if table_name
32
+ if node.name == :timestamps
33
+ name = "timestamps"
34
+ options = {}
35
+ else
36
+ name_arg, *, keyword_args = node.arguments&.arguments
37
+ name = extract_column_name(name_arg)
38
+ options = extract_column_options(keyword_args)
35
39
  end
40
+ return unless name
36
41
 
37
- super
42
+ @columns[name] = { type: node.message.to_sym, options: options }
38
43
  end
39
44
 
40
- def on_send(node)
41
- _receiver, method_name, *args = *node
42
- if method_name == :create_table && args.any?
43
- table_name = extract_table_name(node)
44
- @schema[table_name] ||= {}
45
+ def extract_column_name(node)
46
+ case node
47
+ when Prism::StringNode
48
+ node.content
49
+ when Prism::SymbolNode
50
+ node.value
45
51
  end
46
-
47
- super
48
52
  end
49
53
 
50
- private
51
-
52
- def create_table_call?(node)
53
- return false unless node.is_a?(Parser::AST::Node)
54
-
55
- _receiver, method_name, *_args = node.children
56
- method_name == :create_table
57
- end
54
+ def extract_column_options(node)
55
+ return {} unless node.is_a?(Prism::KeywordHashNode)
58
56
 
59
- def extract_table_name(send_node)
60
- _receiver, _method_name, table_arg, *_rest = send_node.children
61
- return unless table_arg
57
+ options = {}
58
+ node.elements.each do |assoc_node|
59
+ next unless assoc_node.is_a?(Prism::AssocNode)
60
+ next unless (key = extract_key(assoc_node.key))
62
61
 
63
- case table_arg.type
64
- when :str then table_arg.children.first
65
- when :sym then table_arg.children.first.to_s
62
+ options[key] = extract_literal(assoc_node.value)
66
63
  end
64
+ options
67
65
  end
68
66
 
69
- def extract_columns(body_node)
70
- return {} unless body_node
71
-
72
- children = body_node.type == :begin ? body_node.children : [body_node]
73
-
74
- columns = {}
75
- children.each do |expr|
76
- col = process_column_node(expr)
77
- columns[col[:name]] = { type: col[:type], options: col[:options] } if col && col[:name]
67
+ def extract_key(node)
68
+ case node
69
+ when Prism::SymbolNode then node.value.to_sym
70
+ when Prism::StringNode then node.content.to_sym
78
71
  end
79
- columns
80
- end
81
-
82
- def process_column_node(node)
83
- return unless node.is_a?(Parser::AST::Node)
84
- return unless node.type == :send
85
-
86
- receiver, method_name, column_node, *args = node.children
87
-
88
- return unless receiver && receiver.type == :lvar
89
-
90
- return { name: "timestamps", type: :timestamps, options: {} } if method_name == :timestamps
91
-
92
- col_name = extract_column_name(column_node)
93
- options = extract_column_options(args)
94
-
95
- { name: col_name, type: method_name, options: options }
96
72
  end
97
73
 
98
- def extract_column_name(node)
99
- return nil unless node.is_a?(Parser::AST::Node)
100
-
101
- case node.type
102
- when :str then node.children.first
103
- when :sym then node.children.first.to_s
74
+ def extract_literal(node)
75
+ case node
76
+ when Prism::TrueNode then true
77
+ when Prism::FalseNode then false
78
+ when Prism::IntegerNode then node.value
79
+ when Prism::SymbolNode then node.value.to_sym
80
+ when Prism::StringNode then node.content
104
81
  end
105
82
  end
83
+ end
106
84
 
107
- def extract_column_options(args)
108
- opts = {}
109
- args.each do |arg|
110
- next unless arg && arg.type == :hash
85
+ # Internal class used to process the AST and collect schema information.
86
+ class SchemaVisitor < Prism::Visitor
87
+ attr_reader :schema
111
88
 
112
- opts.merge!(parse_hash(arg))
113
- end
114
- opts
89
+ def initialize
90
+ super
91
+ @schema = {}
115
92
  end
116
93
 
117
- def parse_hash(node)
118
- hash = {}
119
- return hash unless node && node.type == :hash
94
+ def visit_call_node(node)
95
+ if node.name == :create_table
96
+ return unless node.block
97
+
98
+ table_name = extract_table_name(node)
99
+ return unless table_name
120
100
 
121
- node.children.each do |pair|
122
- key_node, value_node = pair.children
123
- key = extract_key(key_node)
124
- value = extract_literal(value_node)
125
- hash[key] = value
101
+ columns = extract_columns(node.block)
102
+ @schema[table_name] = columns
103
+ return
126
104
  end
127
- hash
105
+ super
128
106
  end
129
107
 
130
- def extract_key(node)
131
- return unless node.is_a?(Parser::AST::Node)
108
+ private
132
109
 
133
- case node.type
134
- when :sym then node.children.first
135
- when :str then node.children.first.to_sym
110
+ def extract_table_name(call_node)
111
+ first_arg = call_node.arguments&.arguments&.first
112
+ case first_arg
113
+ when Prism::StringNode
114
+ first_arg.content
115
+ when Prism::SymbolNode
116
+ first_arg.value
136
117
  end
137
118
  end
138
119
 
139
- def extract_literal(node)
140
- return unless node.is_a?(Parser::AST::Node)
120
+ def extract_columns(block_node)
121
+ return unless (block_arg = block_node.locals&.first)
141
122
 
142
- case node.type
143
- when :int, :str, :sym then node.children.first
144
- when true then true
145
- when false then false
146
- end
123
+ v = CreateTableVisitor.new(block_arg)
124
+ v.visit(block_node)
125
+ v.columns
147
126
  end
148
127
  end
149
128
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActualDbSchema
4
- VERSION = "0.9.2"
4
+ VERSION = "0.10.0"
5
5
  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.9.2
4
+ version: 0.10.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: 2026-06-14 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -38,20 +38,6 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
- - !ruby/object:Gem::Dependency
42
- name: ast
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - ">="
46
- - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - ">="
53
- - !ruby/object:Gem::Version
54
- version: '0'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: csv
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -66,20 +52,6 @@ dependencies:
66
52
  - - ">="
67
53
  - !ruby/object:Gem::Version
68
54
  version: '0'
69
- - !ruby/object:Gem::Dependency
70
- name: parser
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '0'
76
- type: :runtime
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '0'
83
55
  - !ruby/object:Gem::Dependency
84
56
  name: prism
85
57
  requirement: !ruby/object:Gem::Requirement
@@ -151,9 +123,8 @@ dependencies:
151
123
  - !ruby/object:Gem::Version
152
124
  version: '0'
153
125
  description: |
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.
126
+ Keep Rails databases in sync across Git branches. Automatically roll back stale migrations,
127
+ prevent database errors and unwanted schema diffs, and inspect migration history with a web dashboard.
157
128
  email:
158
129
  - ka8725@gmail.com
159
130
  executables: []
@@ -259,6 +230,6 @@ requirements: []
259
230
  rubygems_version: 3.3.26
260
231
  signing_key:
261
232
  specification_version: 4
262
- summary: Keep DB schema in sync across branches effortlessly.
233
+ summary: Keep Rails databases in sync across Git branches.
263
234
  test_files: []
264
235
  ...