actual_db_schema 0.8.1 → 0.8.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +5 -2
- data/CHANGELOG.md +5 -0
- data/Gemfile.lock +3 -1
- data/README.md +60 -0
- data/actual_db_schema.gemspec +2 -0
- data/lib/actual_db_schema/configuration.rb +3 -1
- data/lib/actual_db_schema/console_migrations.rb +47 -0
- data/lib/actual_db_schema/git_hooks.rb +1 -1
- data/lib/actual_db_schema/migration_parser.rb +264 -0
- data/lib/actual_db_schema/patches/migration_context.rb +1 -1
- data/lib/actual_db_schema/railtie.rb +15 -0
- data/lib/actual_db_schema/schema_diff.rb +221 -0
- data/lib/actual_db_schema/schema_parser.rb +153 -0
- data/lib/actual_db_schema/version.rb +1 -1
- data/lib/actual_db_schema.rb +4 -0
- data/lib/generators/actual_db_schema/templates/actual_db_schema.rb +4 -0
- data/lib/tasks/actual_db_schema.rake +9 -0
- metadata +35 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b4b87fc03cd39a068d303e4e3844a66eb4bd8ced5c7e934c804c0049a3ff141f
|
4
|
+
data.tar.gz: ab30dd516f5c492b2de6e8a41f6d22adcbdc49623a2329ff1948a8114c754823
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: dfa64d7885732bc7e736dcff0c3a66ab5c30d05e80695a729ef8c48c9d478cee8ada3d3dd4566ff0824f884cfd0d244c367915d55971a79ee4a5b60fc53a9e67
|
7
|
+
data.tar.gz: 72a54dd856b12990db02432c63d2aa992e77596ea3af0a4b6e3f83bf31be0a6069edd60222940773adf53f5a3fb286031fa512c7146bacf60fd3fc5424d56036
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,8 @@
|
|
1
|
+
## [0.8.2] - 2025-02-06
|
2
|
+
|
3
|
+
- Show migration name in the schema.rb diff that caused the change
|
4
|
+
- Easy way to run DDL migration methods in Rails console
|
5
|
+
|
1
6
|
## [0.8.1] - 2025-01-15
|
2
7
|
|
3
8
|
- Support for multiple database schemas, ensuring compatibility with multi-tenant applications using the apartment gem or similar solutions
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -168,6 +168,66 @@ config.multi_tenant_schemas = -> { # list of all active schemas }
|
|
168
168
|
config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] }
|
169
169
|
```
|
170
170
|
|
171
|
+
## Schema Diff with Migration Annotations
|
172
|
+
|
173
|
+
If `schema.rb` generates a diff, it can be helpful to find out which migrations caused the changes. This helps you decide whether to resolve the diff on your own or discuss it with your teammates to determine the next steps. The `diff_schema_with_migrations` Rake task generates a diff of the `schema.rb` file, annotated with the migrations responsible for each change. This makes it easier to trace which migration introduced a specific schema modification, enabling faster and more informed decision-making regarding how to handle the diff.
|
174
|
+
|
175
|
+
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.
|
176
|
+
|
177
|
+
### Usage
|
178
|
+
|
179
|
+
Run the task with default paths:
|
180
|
+
```sh
|
181
|
+
rake actual_db_schema:diff_schema_with_migrations
|
182
|
+
```
|
183
|
+
|
184
|
+
Run the task with custom paths:
|
185
|
+
```sh
|
186
|
+
rake actual_db_schema:diff_schema_with_migrations[path/to/custom_schema.rb, path/to/custom_migrations]
|
187
|
+
```
|
188
|
+
|
189
|
+
## Console Migrations
|
190
|
+
|
191
|
+
Sometimes, it's necessary to modify the database without creating migration files. This can be useful for fixing a corrupted schema, conducting experiments (such as adding and removing indexes), or quickly adjusting the schema in development. This gem allows you to run the same commands used in migrations directly in the Rails console.
|
192
|
+
|
193
|
+
By default, Console Migrations is disabled. You can enable it in two ways:
|
194
|
+
|
195
|
+
### 1. Using Environment Variable
|
196
|
+
|
197
|
+
Set the environment variable `ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED` to `true`:
|
198
|
+
|
199
|
+
```sh
|
200
|
+
export ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED=true
|
201
|
+
```
|
202
|
+
|
203
|
+
### 2. Using Initializer
|
204
|
+
|
205
|
+
Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`):
|
206
|
+
|
207
|
+
```ruby
|
208
|
+
config.console_migrations_enabled = true
|
209
|
+
```
|
210
|
+
|
211
|
+
### Usage
|
212
|
+
|
213
|
+
Once enabled, you can run migration commands directly in the Rails console:
|
214
|
+
|
215
|
+
```ruby
|
216
|
+
# Create a new table
|
217
|
+
create_table :posts do |t|
|
218
|
+
t.string :title
|
219
|
+
end
|
220
|
+
|
221
|
+
# Add a column
|
222
|
+
add_column :users, :age, :integer
|
223
|
+
|
224
|
+
# Remove an index
|
225
|
+
remove_index :users, :email
|
226
|
+
|
227
|
+
# Rename a column
|
228
|
+
rename_column :users, :username, :handle
|
229
|
+
```
|
230
|
+
|
171
231
|
## Development
|
172
232
|
|
173
233
|
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.
|
data/actual_db_schema.gemspec
CHANGED
@@ -37,7 +37,9 @@ Gem::Specification.new do |spec|
|
|
37
37
|
# Uncomment to register a new dependency of your gem
|
38
38
|
spec.add_runtime_dependency "activerecord"
|
39
39
|
spec.add_runtime_dependency "activesupport"
|
40
|
+
spec.add_runtime_dependency "ast"
|
40
41
|
spec.add_runtime_dependency "csv"
|
42
|
+
spec.add_runtime_dependency "parser"
|
41
43
|
|
42
44
|
spec.add_development_dependency "appraisal"
|
43
45
|
spec.add_development_dependency "debug"
|
@@ -3,7 +3,8 @@
|
|
3
3
|
module ActualDbSchema
|
4
4
|
# Manages the configuration settings for the gem.
|
5
5
|
class Configuration
|
6
|
-
attr_accessor :enabled, :auto_rollback_disabled, :ui_enabled, :git_hooks_enabled, :multi_tenant_schemas
|
6
|
+
attr_accessor :enabled, :auto_rollback_disabled, :ui_enabled, :git_hooks_enabled, :multi_tenant_schemas,
|
7
|
+
:console_migrations_enabled
|
7
8
|
|
8
9
|
def initialize
|
9
10
|
@enabled = Rails.env.development?
|
@@ -11,6 +12,7 @@ module ActualDbSchema
|
|
11
12
|
@ui_enabled = Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
|
12
13
|
@git_hooks_enabled = ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?
|
13
14
|
@multi_tenant_schemas = nil
|
15
|
+
@console_migrations_enabled = ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?
|
14
16
|
end
|
15
17
|
|
16
18
|
def [](key)
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActualDbSchema
|
4
|
+
# Provides methods for executing schema modification commands directly in the Rails console.
|
5
|
+
module ConsoleMigrations
|
6
|
+
extend self
|
7
|
+
|
8
|
+
SCHEMA_METHODS = %i[
|
9
|
+
create_table
|
10
|
+
create_join_table
|
11
|
+
drop_table
|
12
|
+
change_table
|
13
|
+
add_column
|
14
|
+
remove_column
|
15
|
+
change_column
|
16
|
+
change_column_null
|
17
|
+
change_column_default
|
18
|
+
rename_column
|
19
|
+
add_index
|
20
|
+
remove_index
|
21
|
+
rename_index
|
22
|
+
add_timestamps
|
23
|
+
remove_timestamps
|
24
|
+
reversible
|
25
|
+
add_reference
|
26
|
+
remove_reference
|
27
|
+
add_foreign_key
|
28
|
+
remove_foreign_key
|
29
|
+
].freeze
|
30
|
+
|
31
|
+
SCHEMA_METHODS.each do |method_name|
|
32
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
33
|
+
if kwargs.any?
|
34
|
+
migration_instance.public_send(method_name, *args, **kwargs, &block)
|
35
|
+
else
|
36
|
+
migration_instance.public_send(method_name, *args, &block)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def migration_instance
|
44
|
+
@migration_instance ||= Class.new(ActiveRecord::Migration[ActiveRecord::Migration.current_version]) {}.new
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -4,7 +4,7 @@ require "fileutils"
|
|
4
4
|
|
5
5
|
module ActualDbSchema
|
6
6
|
# Handles the installation of a git post-checkout hook that rolls back phantom migrations when switching branches
|
7
|
-
class GitHooks
|
7
|
+
class GitHooks
|
8
8
|
include ActualDbSchema::OutputFormatter
|
9
9
|
|
10
10
|
POST_CHECKOUT_MARKER_START = "# >>> BEGIN ACTUAL_DB_SCHEMA"
|
@@ -0,0 +1,264 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "parser/current"
|
4
|
+
require "ast"
|
5
|
+
|
6
|
+
module ActualDbSchema
|
7
|
+
# Parses migration files in a Rails application into a structured hash representation.
|
8
|
+
module MigrationParser
|
9
|
+
extend self
|
10
|
+
|
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
|
+
def parse_all_migrations(dirs)
|
24
|
+
changes_by_path = {}
|
25
|
+
handled_files = Set.new
|
26
|
+
|
27
|
+
dirs.each do |dir|
|
28
|
+
Dir["#{dir}/*.rb"].sort.each do |file|
|
29
|
+
base_name = File.basename(file)
|
30
|
+
next if handled_files.include?(base_name)
|
31
|
+
|
32
|
+
changes = parse_file(file).yield_self { |ast| find_migration_changes(ast) }
|
33
|
+
changes_by_path[file] = changes unless changes.empty?
|
34
|
+
handled_files.add(base_name)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
changes_by_path
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def parse_file(file_path)
|
44
|
+
buffer = Parser::Source::Buffer.new(file_path)
|
45
|
+
buffer.source = File.read(file_path, encoding: "UTF-8")
|
46
|
+
Parser::CurrentRuby.parse(buffer.source)
|
47
|
+
end
|
48
|
+
|
49
|
+
def find_migration_changes(node)
|
50
|
+
return [] unless node.is_a?(Parser::AST::Node)
|
51
|
+
|
52
|
+
changes = []
|
53
|
+
if node.type == :block
|
54
|
+
return process_block_node(node)
|
55
|
+
elsif node.type == :send
|
56
|
+
changes.concat(process_send_node(node))
|
57
|
+
end
|
58
|
+
|
59
|
+
node.children.each { |child| changes.concat(find_migration_changes(child)) if child.is_a?(Parser::AST::Node) }
|
60
|
+
|
61
|
+
changes
|
62
|
+
end
|
63
|
+
|
64
|
+
def process_block_node(node)
|
65
|
+
changes = []
|
66
|
+
send_node = node.children.first
|
67
|
+
return changes unless send_node.type == :send
|
68
|
+
|
69
|
+
method_name = send_node.children[1]
|
70
|
+
return changes unless method_name == :create_table
|
71
|
+
|
72
|
+
change = parse_create_table_with_block(send_node, node)
|
73
|
+
changes << change if change
|
74
|
+
changes
|
75
|
+
end
|
76
|
+
|
77
|
+
def process_send_node(node)
|
78
|
+
changes = []
|
79
|
+
_receiver, method_name, *args = node.children
|
80
|
+
if (parser = PARSER_MAPPING[method_name])
|
81
|
+
change = parser.call(args)
|
82
|
+
changes << change if change
|
83
|
+
end
|
84
|
+
|
85
|
+
changes
|
86
|
+
end
|
87
|
+
|
88
|
+
def parse_add_column(args)
|
89
|
+
return unless args.size >= 3
|
90
|
+
|
91
|
+
{
|
92
|
+
action: :add_column,
|
93
|
+
table: sym_value(args[0]),
|
94
|
+
column: sym_value(args[1]),
|
95
|
+
type: sym_value(args[2]),
|
96
|
+
options: parse_hash(args[3])
|
97
|
+
}
|
98
|
+
end
|
99
|
+
|
100
|
+
def parse_change_column(args)
|
101
|
+
return unless args.size >= 3
|
102
|
+
|
103
|
+
{
|
104
|
+
action: :change_column,
|
105
|
+
table: sym_value(args[0]),
|
106
|
+
column: sym_value(args[1]),
|
107
|
+
type: sym_value(args[2]),
|
108
|
+
options: parse_hash(args[3])
|
109
|
+
}
|
110
|
+
end
|
111
|
+
|
112
|
+
def parse_remove_column(args)
|
113
|
+
return unless args.size >= 2
|
114
|
+
|
115
|
+
{
|
116
|
+
action: :remove_column,
|
117
|
+
table: sym_value(args[0]),
|
118
|
+
column: sym_value(args[1]),
|
119
|
+
options: parse_hash(args[2])
|
120
|
+
}
|
121
|
+
end
|
122
|
+
|
123
|
+
def parse_rename_column(args)
|
124
|
+
return unless args.size >= 3
|
125
|
+
|
126
|
+
{
|
127
|
+
action: :rename_column,
|
128
|
+
table: sym_value(args[0]),
|
129
|
+
old_column: sym_value(args[1]),
|
130
|
+
new_column: sym_value(args[2])
|
131
|
+
}
|
132
|
+
end
|
133
|
+
|
134
|
+
def parse_add_index(args)
|
135
|
+
return unless args.size >= 2
|
136
|
+
|
137
|
+
{
|
138
|
+
action: :add_index,
|
139
|
+
table: sym_value(args[0]),
|
140
|
+
columns: array_or_single_value(args[1]),
|
141
|
+
options: parse_hash(args[2])
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
def parse_remove_index(args)
|
146
|
+
return unless args.size >= 1
|
147
|
+
|
148
|
+
{
|
149
|
+
action: :remove_index,
|
150
|
+
table: sym_value(args[0]),
|
151
|
+
options: parse_hash(args[1])
|
152
|
+
}
|
153
|
+
end
|
154
|
+
|
155
|
+
def parse_rename_index(args)
|
156
|
+
return unless args.size >= 3
|
157
|
+
|
158
|
+
{
|
159
|
+
action: :rename_index,
|
160
|
+
table: sym_value(args[0]),
|
161
|
+
old_name: node_value(args[1]),
|
162
|
+
new_name: node_value(args[2])
|
163
|
+
}
|
164
|
+
end
|
165
|
+
|
166
|
+
def parse_create_table(args)
|
167
|
+
return unless args.size >= 1
|
168
|
+
|
169
|
+
{
|
170
|
+
action: :create_table,
|
171
|
+
table: sym_value(args[0]),
|
172
|
+
options: parse_hash(args[1])
|
173
|
+
}
|
174
|
+
end
|
175
|
+
|
176
|
+
def parse_drop_table(args)
|
177
|
+
return unless args.size >= 1
|
178
|
+
|
179
|
+
{
|
180
|
+
action: :drop_table,
|
181
|
+
table: sym_value(args[0]),
|
182
|
+
options: parse_hash(args[1])
|
183
|
+
}
|
184
|
+
end
|
185
|
+
|
186
|
+
def parse_create_table_with_block(send_node, block_node)
|
187
|
+
args = send_node.children[2..]
|
188
|
+
columns = parse_create_table_columns(block_node.children[2])
|
189
|
+
{
|
190
|
+
action: :create_table,
|
191
|
+
table: sym_value(args[0]),
|
192
|
+
options: parse_hash(args[1]),
|
193
|
+
columns: columns
|
194
|
+
}
|
195
|
+
end
|
196
|
+
|
197
|
+
def parse_create_table_columns(body_node)
|
198
|
+
return [] unless body_node
|
199
|
+
|
200
|
+
nodes = body_node.type == :begin ? body_node.children : [body_node]
|
201
|
+
nodes.map { |node| parse_column_node(node) }.compact
|
202
|
+
end
|
203
|
+
|
204
|
+
def parse_column_node(node)
|
205
|
+
return unless node.is_a?(Parser::AST::Node) && node.type == :send
|
206
|
+
|
207
|
+
method = node.children[1]
|
208
|
+
return parse_timestamps if method == :timestamps
|
209
|
+
|
210
|
+
{
|
211
|
+
column: sym_value(node.children[2]),
|
212
|
+
type: method,
|
213
|
+
options: parse_hash(node.children[3])
|
214
|
+
}
|
215
|
+
end
|
216
|
+
|
217
|
+
def parse_timestamps
|
218
|
+
[
|
219
|
+
{ column: :created_at, type: :datetime, options: { null: false } },
|
220
|
+
{ column: :updated_at, type: :datetime, options: { null: false } }
|
221
|
+
]
|
222
|
+
end
|
223
|
+
|
224
|
+
def sym_value(node)
|
225
|
+
return nil unless node && node.type == :sym
|
226
|
+
|
227
|
+
node.children.first
|
228
|
+
end
|
229
|
+
|
230
|
+
def array_or_single_value(node)
|
231
|
+
return [] unless node
|
232
|
+
|
233
|
+
if node.type == :array
|
234
|
+
node.children.map { |child| node_value(child) }
|
235
|
+
else
|
236
|
+
node_value(node)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
def parse_hash(node)
|
241
|
+
return {} unless node && node.type == :hash
|
242
|
+
|
243
|
+
node.children.each_with_object({}) do |pair_node, result|
|
244
|
+
key_node, value_node = pair_node.children
|
245
|
+
key = sym_value(key_node) || node_value(key_node)
|
246
|
+
value = node_value(value_node)
|
247
|
+
result[key] = value
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def node_value(node)
|
252
|
+
return nil unless node
|
253
|
+
|
254
|
+
case node.type
|
255
|
+
when :str, :sym, :int then node.children.first
|
256
|
+
when true then true
|
257
|
+
when false then false
|
258
|
+
when nil then nil
|
259
|
+
else
|
260
|
+
node.children.first
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
@@ -3,7 +3,7 @@
|
|
3
3
|
module ActualDbSchema
|
4
4
|
module Patches
|
5
5
|
# Add new command to roll back the phantom migrations
|
6
|
-
module MigrationContext
|
6
|
+
module MigrationContext
|
7
7
|
include ActualDbSchema::OutputFormatter
|
8
8
|
|
9
9
|
def rollback_branches(manual_mode: false)
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActualDbSchema
|
4
|
+
# Integrates the ConsoleMigrations module into the Rails console.
|
5
|
+
class Railtie < ::Rails::Railtie
|
6
|
+
console do
|
7
|
+
require_relative "console_migrations"
|
8
|
+
|
9
|
+
if ActualDbSchema.config[:console_migrations_enabled]
|
10
|
+
TOPLEVEL_BINDING.receiver.extend(ActualDbSchema::ConsoleMigrations)
|
11
|
+
puts "[ActualDbSchema] ConsoleMigrations enabled. You can now use migration methods directly at the console."
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tempfile"
|
4
|
+
|
5
|
+
module ActualDbSchema
|
6
|
+
# Generates a diff of schema changes between the current schema file and the
|
7
|
+
# last committed version, annotated with the migrations responsible for each change.
|
8
|
+
class SchemaDiff
|
9
|
+
include OutputFormatter
|
10
|
+
|
11
|
+
SIGN_COLORS = {
|
12
|
+
"+" => :green,
|
13
|
+
"-" => :red
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
CHANGE_PATTERNS = {
|
17
|
+
/t\.(\w+)\s+["']([^"']+)["']/ => :column,
|
18
|
+
/t\.index\s+.*name:\s*["']([^"']+)["']/ => :index,
|
19
|
+
/create_table\s+["']([^"']+)["']/ => :table
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
def initialize(schema_path, migrations_path)
|
23
|
+
@schema_path = schema_path
|
24
|
+
@migrations_path = migrations_path
|
25
|
+
end
|
26
|
+
|
27
|
+
def render
|
28
|
+
if old_schema_content.nil? || old_schema_content.strip.empty?
|
29
|
+
puts colorize("Could not retrieve old schema from git.", :red)
|
30
|
+
return
|
31
|
+
end
|
32
|
+
|
33
|
+
diff_output = generate_diff(old_schema_content, new_schema_content)
|
34
|
+
process_diff_output(diff_output)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def old_schema_content
|
40
|
+
@old_schema_content ||= begin
|
41
|
+
output = `git show HEAD:#{@schema_path} 2>&1`
|
42
|
+
$CHILD_STATUS.success? ? output : nil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def new_schema_content
|
47
|
+
@new_schema_content ||= File.read(@schema_path)
|
48
|
+
end
|
49
|
+
|
50
|
+
def parsed_old_schema
|
51
|
+
@parsed_old_schema ||= SchemaParser.parse_string(old_schema_content.to_s)
|
52
|
+
end
|
53
|
+
|
54
|
+
def parsed_new_schema
|
55
|
+
@parsed_new_schema ||= SchemaParser.parse_string(new_schema_content.to_s)
|
56
|
+
end
|
57
|
+
|
58
|
+
def migration_changes
|
59
|
+
@migration_changes ||= begin
|
60
|
+
migration_dirs = [@migrations_path] + migrated_folders
|
61
|
+
MigrationParser.parse_all_migrations(migration_dirs)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def migrated_folders
|
66
|
+
path_parts = Pathname.new(@migrations_path).each_filename.to_a
|
67
|
+
db_index = path_parts.index("db")
|
68
|
+
|
69
|
+
return [] unless db_index
|
70
|
+
|
71
|
+
base_path = db_index.zero? ? "." : File.join(*path_parts[0...db_index])
|
72
|
+
dirs = Dir[File.join(base_path, "tmp", "migrated*")].select do |path|
|
73
|
+
File.directory?(path) && File.basename(path).match?(/^migrated(_[a-zA-Z0-9_-]+)?$/)
|
74
|
+
end
|
75
|
+
|
76
|
+
dirs.map { |dir| dir.sub(%r{\A\./}, "") }
|
77
|
+
end
|
78
|
+
|
79
|
+
def generate_diff(old_content, new_content)
|
80
|
+
Tempfile.create("old_schema") do |old_file|
|
81
|
+
Tempfile.create("new_schema") do |new_file|
|
82
|
+
old_file.write(old_content)
|
83
|
+
new_file.write(new_content)
|
84
|
+
old_file.rewind
|
85
|
+
new_file.rewind
|
86
|
+
|
87
|
+
return `diff -u #{old_file.path} #{new_file.path}`
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def process_diff_output(diff_str)
|
93
|
+
lines = diff_str.lines
|
94
|
+
current_table = nil
|
95
|
+
result_lines = []
|
96
|
+
|
97
|
+
lines.each do |line|
|
98
|
+
if (hunk_match = line.match(/^@@\s+-(\d+),(\d+)\s+\+(\d+),(\d+)\s+@@/))
|
99
|
+
current_table = find_table_in_new_schema(hunk_match[3].to_i)
|
100
|
+
elsif (ct = line.match(/create_table\s+["']([^"']+)["']/))
|
101
|
+
current_table = ct[1]
|
102
|
+
end
|
103
|
+
|
104
|
+
result_lines << (%w[+ -].include?(line[0]) ? handle_diff_line(line, current_table) : line)
|
105
|
+
end
|
106
|
+
|
107
|
+
result_lines.join
|
108
|
+
end
|
109
|
+
|
110
|
+
def handle_diff_line(line, current_table)
|
111
|
+
sign = line[0]
|
112
|
+
line_content = line[1..]
|
113
|
+
color = SIGN_COLORS[sign]
|
114
|
+
|
115
|
+
action, name = detect_action_and_name(line_content, sign, current_table)
|
116
|
+
annotation = action ? find_migrations(action, current_table, name) : []
|
117
|
+
annotated_line = annotation.any? ? annotate_line(line, annotation) : line
|
118
|
+
|
119
|
+
colorize(annotated_line, color)
|
120
|
+
end
|
121
|
+
|
122
|
+
def detect_action_and_name(line_content, sign, current_table)
|
123
|
+
action_map = {
|
124
|
+
column: ->(md) { [guess_action(sign, current_table, md[2]), md[2]] },
|
125
|
+
index: ->(md) { [sign == "+" ? :add_index : :remove_index, md[1]] },
|
126
|
+
table: ->(_) { [sign == "+" ? :create_table : :drop_table, nil] }
|
127
|
+
}
|
128
|
+
|
129
|
+
CHANGE_PATTERNS.each do |regex, kind|
|
130
|
+
next unless (md = line_content.match(regex))
|
131
|
+
|
132
|
+
action_proc = action_map[kind]
|
133
|
+
return action_proc.call(md) if action_proc
|
134
|
+
end
|
135
|
+
|
136
|
+
[nil, nil]
|
137
|
+
end
|
138
|
+
|
139
|
+
def guess_action(sign, table, col_name)
|
140
|
+
case sign
|
141
|
+
when "+"
|
142
|
+
old_table = parsed_old_schema[table] || {}
|
143
|
+
old_table[col_name].nil? ? :add_column : :change_column
|
144
|
+
when "-"
|
145
|
+
new_table = parsed_new_schema[table] || {}
|
146
|
+
new_table[col_name].nil? ? :remove_column : :change_column
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def find_table_in_new_schema(new_line_number)
|
151
|
+
current_table = nil
|
152
|
+
|
153
|
+
new_schema_content.lines[0...new_line_number].each do |line|
|
154
|
+
if (match = line.match(/create_table\s+["']([^"']+)["']/))
|
155
|
+
current_table = match[1]
|
156
|
+
end
|
157
|
+
end
|
158
|
+
current_table
|
159
|
+
end
|
160
|
+
|
161
|
+
def find_migrations(action, table_name, col_or_index_name)
|
162
|
+
matches = []
|
163
|
+
|
164
|
+
migration_changes.each do |file_path, changes|
|
165
|
+
changes.each do |chg|
|
166
|
+
next unless chg[:table].to_s == table_name.to_s
|
167
|
+
|
168
|
+
matches << file_path if migration_matches?(chg, action, col_or_index_name)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
matches
|
173
|
+
end
|
174
|
+
|
175
|
+
def migration_matches?(chg, action, col_or_index_name)
|
176
|
+
return (chg[:action] == action) if col_or_index_name.nil?
|
177
|
+
|
178
|
+
matchers = {
|
179
|
+
rename_column: -> { rename_column_matches?(chg, action, col_or_index_name) },
|
180
|
+
rename_index: -> { rename_index_matches?(chg, action, col_or_index_name) },
|
181
|
+
add_index: -> { index_matches?(chg, action, col_or_index_name) },
|
182
|
+
remove_index: -> { index_matches?(chg, action, col_or_index_name) }
|
183
|
+
}
|
184
|
+
|
185
|
+
matchers.fetch(chg[:action], -> { column_matches?(chg, action, col_or_index_name) }).call
|
186
|
+
end
|
187
|
+
|
188
|
+
def rename_column_matches?(chg, action, col)
|
189
|
+
(action == :remove_column && chg[:old_column].to_s == col.to_s) ||
|
190
|
+
(action == :add_column && chg[:new_column].to_s == col.to_s)
|
191
|
+
end
|
192
|
+
|
193
|
+
def rename_index_matches?(chg, action, name)
|
194
|
+
(action == :remove_index && chg[:old_name] == name) ||
|
195
|
+
(action == :add_index && chg[:new_name] == name)
|
196
|
+
end
|
197
|
+
|
198
|
+
def index_matches?(chg, action, col_or_index_name)
|
199
|
+
return false unless chg[:action] == action
|
200
|
+
|
201
|
+
extract_migration_index_name(chg, chg[:table]) == col_or_index_name.to_s
|
202
|
+
end
|
203
|
+
|
204
|
+
def column_matches?(chg, action, col_name)
|
205
|
+
chg[:column] && chg[:column].to_s == col_name.to_s && chg[:action] == action
|
206
|
+
end
|
207
|
+
|
208
|
+
def extract_migration_index_name(chg, table_name)
|
209
|
+
return chg[:options][:name].to_s if chg[:options].is_a?(Hash) && chg[:options][:name]
|
210
|
+
|
211
|
+
return "" unless (columns = chg[:columns])
|
212
|
+
|
213
|
+
cols = columns.is_a?(Array) ? columns : [columns]
|
214
|
+
"index_#{table_name}_on_#{cols.join("_and_")}"
|
215
|
+
end
|
216
|
+
|
217
|
+
def annotate_line(line, migration_file_paths)
|
218
|
+
"#{line.chomp}#{colorize(" // #{migration_file_paths.join(", ")} //", :gray)}\n"
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "parser/current"
|
4
|
+
require "parser/ast/processor"
|
5
|
+
|
6
|
+
module ActualDbSchema
|
7
|
+
# Parses the content of a `schema.rb` file into a structured hash representation.
|
8
|
+
module SchemaParser
|
9
|
+
module_function
|
10
|
+
|
11
|
+
def parse_string(schema_content)
|
12
|
+
buffer = Parser::Source::Buffer.new("(schema)")
|
13
|
+
buffer.source = schema_content
|
14
|
+
parser = Parser::CurrentRuby.new
|
15
|
+
ast = parser.parse(buffer)
|
16
|
+
|
17
|
+
collector = SchemaCollector.new
|
18
|
+
collector.process(ast)
|
19
|
+
collector.schema
|
20
|
+
end
|
21
|
+
|
22
|
+
# Internal class used to process the AST and collect schema information.
|
23
|
+
class SchemaCollector < Parser::AST::Processor
|
24
|
+
attr_reader :schema
|
25
|
+
|
26
|
+
def initialize
|
27
|
+
super()
|
28
|
+
@schema = {}
|
29
|
+
end
|
30
|
+
|
31
|
+
def on_block(node)
|
32
|
+
send_node, _args_node, body = *node
|
33
|
+
|
34
|
+
if create_table_call?(send_node)
|
35
|
+
table_name = extract_table_name(send_node)
|
36
|
+
columns = extract_columns(body)
|
37
|
+
@schema[table_name] = columns if table_name
|
38
|
+
end
|
39
|
+
|
40
|
+
super
|
41
|
+
end
|
42
|
+
|
43
|
+
def on_send(node)
|
44
|
+
_receiver, method_name, *args = *node
|
45
|
+
if method_name == :create_table && args.any?
|
46
|
+
table_name = extract_table_name(node)
|
47
|
+
@schema[table_name] ||= {}
|
48
|
+
end
|
49
|
+
|
50
|
+
super
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def create_table_call?(node)
|
56
|
+
return false unless node.is_a?(Parser::AST::Node)
|
57
|
+
|
58
|
+
_receiver, method_name, *_args = node.children
|
59
|
+
method_name == :create_table
|
60
|
+
end
|
61
|
+
|
62
|
+
def extract_table_name(send_node)
|
63
|
+
_receiver, _method_name, table_arg, *_rest = send_node.children
|
64
|
+
return unless table_arg
|
65
|
+
|
66
|
+
case table_arg.type
|
67
|
+
when :str then table_arg.children.first
|
68
|
+
when :sym then table_arg.children.first.to_s
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def extract_columns(body_node)
|
73
|
+
return {} unless body_node
|
74
|
+
|
75
|
+
children = body_node.type == :begin ? body_node.children : [body_node]
|
76
|
+
|
77
|
+
columns = {}
|
78
|
+
children.each do |expr|
|
79
|
+
col = process_column_node(expr)
|
80
|
+
columns[col[:name]] = { type: col[:type], options: col[:options] } if col && col[:name]
|
81
|
+
end
|
82
|
+
columns
|
83
|
+
end
|
84
|
+
|
85
|
+
def process_column_node(node)
|
86
|
+
return unless node.is_a?(Parser::AST::Node)
|
87
|
+
return unless node.type == :send
|
88
|
+
|
89
|
+
receiver, method_name, column_node, *args = node.children
|
90
|
+
|
91
|
+
return unless receiver && receiver.type == :lvar
|
92
|
+
|
93
|
+
return { name: "timestamps", type: :timestamps, options: {} } if method_name == :timestamps
|
94
|
+
|
95
|
+
col_name = extract_column_name(column_node)
|
96
|
+
options = extract_column_options(args)
|
97
|
+
|
98
|
+
{ name: col_name, type: method_name, options: options }
|
99
|
+
end
|
100
|
+
|
101
|
+
def extract_column_name(node)
|
102
|
+
return nil unless node.is_a?(Parser::AST::Node)
|
103
|
+
|
104
|
+
case node.type
|
105
|
+
when :str then node.children.first
|
106
|
+
when :sym then node.children.first.to_s
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def extract_column_options(args)
|
111
|
+
opts = {}
|
112
|
+
args.each do |arg|
|
113
|
+
next unless arg && arg.type == :hash
|
114
|
+
|
115
|
+
opts.merge!(parse_hash(arg))
|
116
|
+
end
|
117
|
+
opts
|
118
|
+
end
|
119
|
+
|
120
|
+
def parse_hash(node)
|
121
|
+
hash = {}
|
122
|
+
return hash unless node && node.type == :hash
|
123
|
+
|
124
|
+
node.children.each do |pair|
|
125
|
+
key_node, value_node = pair.children
|
126
|
+
key = extract_key(key_node)
|
127
|
+
value = extract_literal(value_node)
|
128
|
+
hash[key] = value
|
129
|
+
end
|
130
|
+
hash
|
131
|
+
end
|
132
|
+
|
133
|
+
def extract_key(node)
|
134
|
+
return unless node.is_a?(Parser::AST::Node)
|
135
|
+
|
136
|
+
case node.type
|
137
|
+
when :sym then node.children.first
|
138
|
+
when :str then node.children.first.to_sym
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def extract_literal(node)
|
143
|
+
return unless node.is_a?(Parser::AST::Node)
|
144
|
+
|
145
|
+
case node.type
|
146
|
+
when :int, :str, :sym then node.children.first
|
147
|
+
when true then true
|
148
|
+
when false then false
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
data/lib/actual_db_schema.rb
CHANGED
@@ -10,12 +10,16 @@ require_relative "actual_db_schema/version"
|
|
10
10
|
require_relative "actual_db_schema/migration"
|
11
11
|
require_relative "actual_db_schema/failed_migration"
|
12
12
|
require_relative "actual_db_schema/migration_context"
|
13
|
+
require_relative "actual_db_schema/migration_parser"
|
13
14
|
require_relative "actual_db_schema/output_formatter"
|
14
15
|
require_relative "actual_db_schema/patches/migration_proxy"
|
15
16
|
require_relative "actual_db_schema/patches/migrator"
|
16
17
|
require_relative "actual_db_schema/patches/migration_context"
|
17
18
|
require_relative "actual_db_schema/git_hooks"
|
18
19
|
require_relative "actual_db_schema/multi_tenant"
|
20
|
+
require_relative "actual_db_schema/railtie"
|
21
|
+
require_relative "actual_db_schema/schema_diff"
|
22
|
+
require_relative "actual_db_schema/schema_parser"
|
19
23
|
|
20
24
|
require_relative "actual_db_schema/commands/base"
|
21
25
|
require_relative "actual_db_schema/commands/rollback"
|
@@ -20,4 +20,8 @@ ActualDbSchema.configure do |config|
|
|
20
20
|
|
21
21
|
# If your application leverages multiple schemas for multi-tenancy, define the active schemas.
|
22
22
|
# config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] }
|
23
|
+
|
24
|
+
# Enable console migrations
|
25
|
+
# config.console_migrations_enabled = true
|
26
|
+
config.console_migrations_enabled = ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?
|
23
27
|
end
|
@@ -53,4 +53,13 @@ namespace :actual_db_schema do # rubocop:disable Metrics/BlockLength
|
|
53
53
|
ActualDbSchema::GitHooks.new(strategy: strategy).install_post_checkout_hook
|
54
54
|
end
|
55
55
|
end
|
56
|
+
|
57
|
+
desc "Show the schema.rb diff annotated with the migrations that made the changes"
|
58
|
+
task :diff_schema_with_migrations, [:schema_path, :migrations_path] do |_, args|
|
59
|
+
schema_path = args[:schema_path] || "db/schema.rb"
|
60
|
+
migrations_path = args[:migrations_path] || "db/migrate"
|
61
|
+
|
62
|
+
schema_diff = ActualDbSchema::SchemaDiff.new(schema_path, migrations_path)
|
63
|
+
puts schema_diff.render
|
64
|
+
end
|
56
65
|
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.8.
|
4
|
+
version: 0.8.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrei Kaleshka
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-02-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -38,6 +38,20 @@ 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'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: csv
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,6 +66,20 @@ dependencies:
|
|
52
66
|
- - ">="
|
53
67
|
- !ruby/object:Gem::Version
|
54
68
|
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'
|
55
83
|
- !ruby/object:Gem::Dependency
|
56
84
|
name: appraisal
|
57
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -149,17 +177,22 @@ files:
|
|
149
177
|
- lib/actual_db_schema/commands/list.rb
|
150
178
|
- lib/actual_db_schema/commands/rollback.rb
|
151
179
|
- lib/actual_db_schema/configuration.rb
|
180
|
+
- lib/actual_db_schema/console_migrations.rb
|
152
181
|
- lib/actual_db_schema/engine.rb
|
153
182
|
- lib/actual_db_schema/failed_migration.rb
|
154
183
|
- lib/actual_db_schema/git.rb
|
155
184
|
- lib/actual_db_schema/git_hooks.rb
|
156
185
|
- lib/actual_db_schema/migration.rb
|
157
186
|
- lib/actual_db_schema/migration_context.rb
|
187
|
+
- lib/actual_db_schema/migration_parser.rb
|
158
188
|
- lib/actual_db_schema/multi_tenant.rb
|
159
189
|
- lib/actual_db_schema/output_formatter.rb
|
160
190
|
- lib/actual_db_schema/patches/migration_context.rb
|
161
191
|
- lib/actual_db_schema/patches/migration_proxy.rb
|
162
192
|
- lib/actual_db_schema/patches/migrator.rb
|
193
|
+
- lib/actual_db_schema/railtie.rb
|
194
|
+
- lib/actual_db_schema/schema_diff.rb
|
195
|
+
- lib/actual_db_schema/schema_parser.rb
|
163
196
|
- lib/actual_db_schema/store.rb
|
164
197
|
- lib/actual_db_schema/version.rb
|
165
198
|
- lib/generators/actual_db_schema/templates/actual_db_schema.rb
|