actual_db_schema 0.8.1 → 0.8.3

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.
@@ -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"
@@ -73,6 +73,43 @@ module ActualDbSchema
73
73
  end
74
74
  end
75
75
 
76
+ def broken_versions
77
+ broken = []
78
+ MigrationContext.instance.each do |context|
79
+ context.migrations_status.each do |status, version, name|
80
+ next unless name == "********** NO FILE **********"
81
+
82
+ broken << Migration.new(
83
+ status: status,
84
+ version: version.to_s,
85
+ name: name,
86
+ branch: branch_for(version),
87
+ database: ActualDbSchema.db_config[:database]
88
+ )
89
+ end
90
+ end
91
+
92
+ broken
93
+ end
94
+
95
+ def delete(version, database)
96
+ validate_broken_migration(version, database)
97
+
98
+ MigrationContext.instance.each do
99
+ next if database && ActualDbSchema.db_config[:database] != database
100
+ next if ActiveRecord::Base.connection.select_values("SELECT version FROM schema_migrations").exclude?(version)
101
+
102
+ ActiveRecord::Base.connection.execute("DELETE FROM schema_migrations WHERE version = '#{version}'")
103
+ break
104
+ end
105
+ end
106
+
107
+ def delete_all
108
+ broken_versions.each do |version|
109
+ delete(version.version, version.database)
110
+ end
111
+ end
112
+
76
113
  private
77
114
 
78
115
  def build_migration_struct(status, migration)
@@ -92,7 +129,8 @@ module ActualDbSchema
92
129
  end
93
130
 
94
131
  def phantom?(migration)
95
- migration.filename.include?("/tmp/migrated")
132
+ migrated_folder = ActualDbSchema.config[:migrated_folder].presence || "/tmp/migrated"
133
+ migration.filename.include?(migrated_folder.to_s)
96
134
  end
97
135
 
98
136
  def should_include?(status, migration)
@@ -115,5 +153,15 @@ module ActualDbSchema
115
153
  @metadata ||= {}
116
154
  @metadata[ActualDbSchema.db_config[:database]] ||= ActualDbSchema::Store.instance.read
117
155
  end
156
+
157
+ def validate_broken_migration(version, database)
158
+ if database
159
+ unless broken_versions.any? { |v| v.version == version && v.database == database }
160
+ raise StandardError, "Migration is not broken for database #{database}."
161
+ end
162
+ else
163
+ raise StandardError, "Migration is not broken." unless broken_versions.any? { |v| v.version == version }
164
+ end
165
+ end
118
166
  end
119
167
  end
@@ -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,229 @@
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
+ dirs = find_migrated_folders
67
+
68
+ if (configured_migrated_folder = ActualDbSchema.config[:migrated_folder].presence)
69
+ relative_migrated_folder = configured_migrated_folder.to_s.sub(%r{\A#{Regexp.escape(Rails.root.to_s)}/?}, "")
70
+ dirs << relative_migrated_folder unless dirs.include?(relative_migrated_folder)
71
+ end
72
+
73
+ dirs.map { |dir| dir.sub(%r{\A\./}, "") }.uniq
74
+ end
75
+
76
+ def find_migrated_folders
77
+ path_parts = Pathname.new(@migrations_path).each_filename.to_a
78
+ db_index = path_parts.index("db")
79
+ return [] unless db_index
80
+
81
+ base_path = db_index.zero? ? "." : File.join(*path_parts[0...db_index])
82
+ Dir[File.join(base_path, "tmp", "migrated*")].select do |path|
83
+ File.directory?(path) && File.basename(path).match?(/^migrated(_[a-zA-Z0-9_-]+)?$/)
84
+ end
85
+ end
86
+
87
+ def generate_diff(old_content, new_content)
88
+ Tempfile.create("old_schema") do |old_file|
89
+ Tempfile.create("new_schema") do |new_file|
90
+ old_file.write(old_content)
91
+ new_file.write(new_content)
92
+ old_file.rewind
93
+ new_file.rewind
94
+
95
+ return `diff -u #{old_file.path} #{new_file.path}`
96
+ end
97
+ end
98
+ end
99
+
100
+ def process_diff_output(diff_str)
101
+ lines = diff_str.lines
102
+ current_table = nil
103
+ result_lines = []
104
+
105
+ lines.each do |line|
106
+ if (hunk_match = line.match(/^@@\s+-(\d+),(\d+)\s+\+(\d+),(\d+)\s+@@/))
107
+ current_table = find_table_in_new_schema(hunk_match[3].to_i)
108
+ elsif (ct = line.match(/create_table\s+["']([^"']+)["']/))
109
+ current_table = ct[1]
110
+ end
111
+
112
+ result_lines << (%w[+ -].include?(line[0]) ? handle_diff_line(line, current_table) : line)
113
+ end
114
+
115
+ result_lines.join
116
+ end
117
+
118
+ def handle_diff_line(line, current_table)
119
+ sign = line[0]
120
+ line_content = line[1..]
121
+ color = SIGN_COLORS[sign]
122
+
123
+ action, name = detect_action_and_name(line_content, sign, current_table)
124
+ annotation = action ? find_migrations(action, current_table, name) : []
125
+ annotated_line = annotation.any? ? annotate_line(line, annotation) : line
126
+
127
+ colorize(annotated_line, color)
128
+ end
129
+
130
+ def detect_action_and_name(line_content, sign, current_table)
131
+ action_map = {
132
+ column: ->(md) { [guess_action(sign, current_table, md[2]), md[2]] },
133
+ index: ->(md) { [sign == "+" ? :add_index : :remove_index, md[1]] },
134
+ table: ->(_) { [sign == "+" ? :create_table : :drop_table, nil] }
135
+ }
136
+
137
+ CHANGE_PATTERNS.each do |regex, kind|
138
+ next unless (md = line_content.match(regex))
139
+
140
+ action_proc = action_map[kind]
141
+ return action_proc.call(md) if action_proc
142
+ end
143
+
144
+ [nil, nil]
145
+ end
146
+
147
+ def guess_action(sign, table, col_name)
148
+ case sign
149
+ when "+"
150
+ old_table = parsed_old_schema[table] || {}
151
+ old_table[col_name].nil? ? :add_column : :change_column
152
+ when "-"
153
+ new_table = parsed_new_schema[table] || {}
154
+ new_table[col_name].nil? ? :remove_column : :change_column
155
+ end
156
+ end
157
+
158
+ def find_table_in_new_schema(new_line_number)
159
+ current_table = nil
160
+
161
+ new_schema_content.lines[0...new_line_number].each do |line|
162
+ if (match = line.match(/create_table\s+["']([^"']+)["']/))
163
+ current_table = match[1]
164
+ end
165
+ end
166
+ current_table
167
+ end
168
+
169
+ def find_migrations(action, table_name, col_or_index_name)
170
+ matches = []
171
+
172
+ migration_changes.each do |file_path, changes|
173
+ changes.each do |chg|
174
+ next unless chg[:table].to_s == table_name.to_s
175
+
176
+ matches << file_path if migration_matches?(chg, action, col_or_index_name)
177
+ end
178
+ end
179
+
180
+ matches
181
+ end
182
+
183
+ def migration_matches?(chg, action, col_or_index_name)
184
+ return (chg[:action] == action) if col_or_index_name.nil?
185
+
186
+ matchers = {
187
+ rename_column: -> { rename_column_matches?(chg, action, col_or_index_name) },
188
+ rename_index: -> { rename_index_matches?(chg, action, col_or_index_name) },
189
+ add_index: -> { index_matches?(chg, action, col_or_index_name) },
190
+ remove_index: -> { index_matches?(chg, action, col_or_index_name) }
191
+ }
192
+
193
+ matchers.fetch(chg[:action], -> { column_matches?(chg, action, col_or_index_name) }).call
194
+ end
195
+
196
+ def rename_column_matches?(chg, action, col)
197
+ (action == :remove_column && chg[:old_column].to_s == col.to_s) ||
198
+ (action == :add_column && chg[:new_column].to_s == col.to_s)
199
+ end
200
+
201
+ def rename_index_matches?(chg, action, name)
202
+ (action == :remove_index && chg[:old_name] == name) ||
203
+ (action == :add_index && chg[:new_name] == name)
204
+ end
205
+
206
+ def index_matches?(chg, action, col_or_index_name)
207
+ return false unless chg[:action] == action
208
+
209
+ extract_migration_index_name(chg, chg[:table]) == col_or_index_name.to_s
210
+ end
211
+
212
+ def column_matches?(chg, action, col_name)
213
+ chg[:column] && chg[:column].to_s == col_name.to_s && chg[:action] == action
214
+ end
215
+
216
+ def extract_migration_index_name(chg, table_name)
217
+ return chg[:options][:name].to_s if chg[:options].is_a?(Hash) && chg[:options][:name]
218
+
219
+ return "" unless (columns = chg[:columns])
220
+
221
+ cols = columns.is_a?(Array) ? columns : [columns]
222
+ "index_#{table_name}_on_#{cols.join("_and_")}"
223
+ end
224
+
225
+ def annotate_line(line, migration_file_paths)
226
+ "#{line.chomp}#{colorize(" // #{migration_file_paths.join(", ")} //", :gray)}\n"
227
+ end
228
+ end
229
+ end