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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9197558d7b71582d339535933b2186c9ce3db7d0c4dd492759db5a226931ddf8
4
- data.tar.gz: ccd05c4164478a54130c6bee32a234abbb2f193292438b8d35b6d7edd313802c
3
+ metadata.gz: b4b87fc03cd39a068d303e4e3844a66eb4bd8ced5c7e934c804c0049a3ff141f
4
+ data.tar.gz: ab30dd516f5c492b2de6e8a41f6d22adcbdc49623a2329ff1948a8114c754823
5
5
  SHA512:
6
- metadata.gz: b12528875d4b7d5b12739b1a37877d979bccc6d66f7a0006454ae260b1215eb69bb5baa2aae332e68bc4f1fa29859e4b00a427591cf78acf2aebc57264889a46
7
- data.tar.gz: ae2baeaae67451740cc6773abbc79e4d0896baf96b99e7c2baebe027c1b7b05bbbbb75da5717be47c43d240835810a1681408e79c3d44449f7f44c3d4e8830a6
6
+ metadata.gz: dfa64d7885732bc7e736dcff0c3a66ab5c30d05e80695a729ef8c48c9d478cee8ada3d3dd4566ff0824f884cfd0d244c367915d55971a79ee4a5b60fc53a9e67
7
+ data.tar.gz: 72a54dd856b12990db02432c63d2aa992e77596ea3af0a4b6e3f83bf31be0a6069edd60222940773adf53f5a3fb286031fa512c7146bacf60fd3fc5424d56036
data/.rubocop.yml CHANGED
@@ -22,9 +22,12 @@ Metrics/BlockLength:
22
22
  - actual_db_schema.gemspec
23
23
 
24
24
  Metrics/MethodLength:
25
+ Max: 15
25
26
  Exclude:
26
27
  - test/**/*
27
28
 
28
29
  Metrics/ClassLength:
29
- Exclude:
30
- - test/**/*
30
+ Enabled: false
31
+
32
+ Metrics/ModuleLength:
33
+ Enabled: false
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
@@ -1,10 +1,12 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- actual_db_schema (0.8.1)
4
+ actual_db_schema (0.8.2)
5
5
  activerecord
6
6
  activesupport
7
+ ast
7
8
  csv
9
+ parser
8
10
 
9
11
  GEM
10
12
  remote: https://rubygems.org/
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.
@@ -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 # rubocop:disable Metrics/ClassLength
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 # rubocop:disable Metrics/ModuleLength
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActualDbSchema
4
- VERSION = "0.8.1"
4
+ VERSION = "0.8.2"
5
5
  end
@@ -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.1
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-01-15 00:00:00.000000000 Z
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