migflow 0.2.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 +7 -0
- data/.rubocop.yml +44 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +45 -0
- data/CLAUDE.md +124 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +157 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/Rakefile +11 -0
- data/SECURITY.md +27 -0
- data/app/assets/migflow/app.css +1 -0
- data/app/assets/migflow/app.js +28 -0
- data/app/assets/migflow/index.html +14 -0
- data/app/assets/migflow/vite.svg +1 -0
- data/app/controllers/migflow/api/diff_controller.rb +73 -0
- data/app/controllers/migflow/api/migrations_controller.rb +97 -0
- data/app/controllers/migflow/application_controller.rb +62 -0
- data/app/views/migflow/application/index.html.erb +16 -0
- data/config/routes.rb +10 -0
- data/docs/architecture.md +130 -0
- data/lib/migflow/analyzers/audit_analyzer.rb +58 -0
- data/lib/migflow/analyzers/rules/base_rule.rb +32 -0
- data/lib/migflow/analyzers/rules/dangerous_migration_rule.rb +44 -0
- data/lib/migflow/analyzers/rules/missing_foreign_key_rule.rb +30 -0
- data/lib/migflow/analyzers/rules/missing_index_rule.rb +32 -0
- data/lib/migflow/analyzers/rules/missing_timestamps_rule.rb +38 -0
- data/lib/migflow/analyzers/rules/null_column_without_default_rule.rb +46 -0
- data/lib/migflow/analyzers/rules/string_without_limit_rule.rb +28 -0
- data/lib/migflow/app/assets/migflow/app.css +1 -0
- data/lib/migflow/app/assets/migflow/app.js +17 -0
- data/lib/migflow/app/assets/migflow/index.html +14 -0
- data/lib/migflow/app/assets/migflow/vite.svg +1 -0
- data/lib/migflow/configuration.rb +36 -0
- data/lib/migflow/engine.rb +14 -0
- data/lib/migflow/models/migration_snapshot.rb +15 -0
- data/lib/migflow/models/schema_diff.rb +9 -0
- data/lib/migflow/models/warning.rb +7 -0
- data/lib/migflow/parsers/migration_parser.rb +52 -0
- data/lib/migflow/parsers/schema_parser.rb +105 -0
- data/lib/migflow/reporters/json_reporter.rb +13 -0
- data/lib/migflow/reporters/markdown_reporter.rb +58 -0
- data/lib/migflow/reporters.rb +38 -0
- data/lib/migflow/services/diff_builder.rb +77 -0
- data/lib/migflow/services/migration_dsl_scanner.rb +161 -0
- data/lib/migflow/services/migration_summary_builder.rb +43 -0
- data/lib/migflow/services/report_generator.rb +76 -0
- data/lib/migflow/services/risk_scorer.rb +38 -0
- data/lib/migflow/services/schema_builder.rb +25 -0
- data/lib/migflow/services/schema_patch_builder.rb +237 -0
- data/lib/migflow/services/scoped_migration_warnings.rb +93 -0
- data/lib/migflow/services/snapshot_builder.rb +542 -0
- data/lib/migflow/services/touched_tables_from_migration.rb +60 -0
- data/lib/migflow/version.rb +5 -0
- data/lib/migflow.rb +20 -0
- data/lib/tasks/migflow.rake +31 -0
- data/sig/migflow.rbs +3 -0
- metadata +124 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "migration_dsl_scanner"
|
|
4
|
+
|
|
5
|
+
module Migflow
|
|
6
|
+
module Services
|
|
7
|
+
class SnapshotBuilder
|
|
8
|
+
def self.call(migrations:, up_to_version:)
|
|
9
|
+
new(migrations: migrations, up_to_version: up_to_version).build
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(migrations:, up_to_version:)
|
|
13
|
+
@migrations = migrations.sort_by { |m| m[:version] }
|
|
14
|
+
@up_to_version = up_to_version
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build
|
|
18
|
+
before = { tables: {} }
|
|
19
|
+
after = { tables: {} }
|
|
20
|
+
|
|
21
|
+
@migrations.each do |migration|
|
|
22
|
+
break if migration[:version] > @up_to_version
|
|
23
|
+
|
|
24
|
+
before = deep_copy(after)
|
|
25
|
+
after = apply_migration(after, migration[:raw_content])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
{ schema_before: before, schema_after: after, diff: calculate_diff(before, after) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def deep_copy(state)
|
|
34
|
+
Marshal.load(Marshal.dump(state))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apply_migration(state, content)
|
|
38
|
+
s = deep_copy(state)
|
|
39
|
+
scanner = MigrationDslScanner.new(content)
|
|
40
|
+
apply_create_tables(s, scanner)
|
|
41
|
+
apply_drop_tables(s, scanner)
|
|
42
|
+
apply_add_columns(s, scanner)
|
|
43
|
+
apply_remove_columns(s, scanner)
|
|
44
|
+
apply_add_references(s, scanner)
|
|
45
|
+
apply_remove_references(s, scanner)
|
|
46
|
+
apply_rename_columns(s, scanner)
|
|
47
|
+
apply_rename_indexes(s, scanner)
|
|
48
|
+
apply_rename_tables(s, scanner)
|
|
49
|
+
apply_change_columns(s, scanner)
|
|
50
|
+
apply_change_column_defaults(s, scanner)
|
|
51
|
+
apply_change_column_nulls(s, scanner)
|
|
52
|
+
apply_change_column_comments(s, scanner)
|
|
53
|
+
apply_add_indexes(s, scanner)
|
|
54
|
+
apply_remove_indexes(s, scanner)
|
|
55
|
+
apply_add_foreign_keys(s, scanner)
|
|
56
|
+
apply_remove_foreign_keys(s, scanner)
|
|
57
|
+
apply_add_check_constraints(s, scanner)
|
|
58
|
+
apply_remove_check_constraints(s, scanner)
|
|
59
|
+
apply_change_table_blocks(s, scanner)
|
|
60
|
+
s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def apply_create_tables(state, scanner)
|
|
64
|
+
scanner.create_table_blocks.each do |table, block|
|
|
65
|
+
state[:tables][table] = {
|
|
66
|
+
columns: parse_block_columns(block),
|
|
67
|
+
indexes: parse_block_indexes(block),
|
|
68
|
+
foreign_keys: [],
|
|
69
|
+
check_constraints: []
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def apply_drop_tables(state, scanner)
|
|
75
|
+
scanner.drop_tables.each { |table| state[:tables].delete(table) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def apply_add_columns(state, scanner)
|
|
79
|
+
scanner.add_columns.each do |table, col, type, opts|
|
|
80
|
+
ensure_table(state, table)
|
|
81
|
+
state[:tables][table][:columns] << build_column(col, type, opts)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply_remove_columns(state, scanner)
|
|
86
|
+
scanner.remove_column.each do |table, col|
|
|
87
|
+
next unless state[:tables][table]
|
|
88
|
+
|
|
89
|
+
state[:tables][table][:columns].reject! { |c| c[:name] == col }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
scanner.remove_columns.each do |table, cols_raw|
|
|
93
|
+
next unless state[:tables][table]
|
|
94
|
+
|
|
95
|
+
extract_columns_list(cols_raw).each do |col|
|
|
96
|
+
state[:tables][table][:columns].reject! { |c| c[:name] == col }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def apply_add_references(state, scanner)
|
|
102
|
+
scanner.add_references.each do |table, ref, opts|
|
|
103
|
+
ensure_table(state, table)
|
|
104
|
+
build_reference_columns(ref, opts).each do |column|
|
|
105
|
+
state[:tables][table][:columns] << column
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def apply_remove_references(state, scanner)
|
|
111
|
+
scanner.remove_references.each do |table, ref, opts|
|
|
112
|
+
next unless state[:tables][table]
|
|
113
|
+
|
|
114
|
+
remove_reference_columns(state[:tables][table], ref, opts)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def apply_rename_columns(state, scanner)
|
|
119
|
+
scanner.rename_columns.each do |table, from, to|
|
|
120
|
+
next unless state[:tables][table]
|
|
121
|
+
|
|
122
|
+
col = state[:tables][table][:columns].find { |c| c[:name] == from }
|
|
123
|
+
col[:name] = to if col
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def apply_rename_indexes(state, scanner)
|
|
128
|
+
scanner.rename_indexes.each do |table, from, to|
|
|
129
|
+
next unless state[:tables][table]
|
|
130
|
+
|
|
131
|
+
from_name = clean_identifier(from)
|
|
132
|
+
to_name = clean_identifier(to)
|
|
133
|
+
idx = state[:tables][table][:indexes].find { |index| index[:name] == from_name }
|
|
134
|
+
idx[:name] = to_name if idx
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def apply_rename_tables(state, scanner)
|
|
139
|
+
scanner.rename_tables.each do |from, to|
|
|
140
|
+
next unless state[:tables][from]
|
|
141
|
+
|
|
142
|
+
state[:tables][to] = state[:tables].delete(from)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def apply_change_columns(state, scanner)
|
|
147
|
+
scanner.change_columns.each do |table, col, type|
|
|
148
|
+
next unless state[:tables][table]
|
|
149
|
+
|
|
150
|
+
existing = state[:tables][table][:columns].find { |c| c[:name] == col }
|
|
151
|
+
existing[:type] = type if existing
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def apply_change_column_defaults(state, scanner)
|
|
156
|
+
scanner.change_column_defaults.each do |table, col, options|
|
|
157
|
+
next unless state[:tables][table]
|
|
158
|
+
|
|
159
|
+
existing = state[:tables][table][:columns].find { |c| c[:name] == col }
|
|
160
|
+
next unless existing
|
|
161
|
+
|
|
162
|
+
existing[:default] = extract_default_value(options)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def apply_change_column_nulls(state, scanner)
|
|
167
|
+
scanner.change_column_nulls.each do |table, col, nullable, default_value|
|
|
168
|
+
next unless state[:tables][table]
|
|
169
|
+
|
|
170
|
+
existing = state[:tables][table][:columns].find { |c| c[:name] == col }
|
|
171
|
+
next unless existing
|
|
172
|
+
|
|
173
|
+
existing[:null] = nullable == "true"
|
|
174
|
+
existing[:default] = extract_default_value(default_value) if default_value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def apply_change_column_comments(state, scanner)
|
|
179
|
+
scanner.change_column_comments.each do |table, col, comment|
|
|
180
|
+
next unless state[:tables][table]
|
|
181
|
+
|
|
182
|
+
existing = state[:tables][table][:columns].find { |c| c[:name] == col }
|
|
183
|
+
existing[:comment] = extract_default_value(comment) if existing
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def apply_add_indexes(state, scanner)
|
|
188
|
+
scanner.add_indexes.each do |table, cols_raw, opts|
|
|
189
|
+
next unless state[:tables][table]
|
|
190
|
+
|
|
191
|
+
state[:tables][table][:indexes] << build_index(cols_raw, opts)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def apply_remove_indexes(state, scanner)
|
|
196
|
+
scanner.remove_indexes.each do |table, args|
|
|
197
|
+
next unless state[:tables][table]
|
|
198
|
+
|
|
199
|
+
remove_index_from_table(state[:tables][table], args)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def apply_add_foreign_keys(state, scanner)
|
|
204
|
+
scanner.add_foreign_keys.each do |table, to_table, opts|
|
|
205
|
+
next unless state[:tables][table]
|
|
206
|
+
|
|
207
|
+
state[:tables][table][:foreign_keys] << build_foreign_key(to_table, opts)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def apply_remove_foreign_keys(state, scanner)
|
|
212
|
+
scanner.remove_foreign_keys.each do |table, args|
|
|
213
|
+
next unless state[:tables][table]
|
|
214
|
+
|
|
215
|
+
remove_foreign_key_from_table(state[:tables][table], args)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def apply_add_check_constraints(state, scanner)
|
|
220
|
+
scanner.add_check_constraints.each do |table, expression, opts|
|
|
221
|
+
next unless state[:tables][table]
|
|
222
|
+
|
|
223
|
+
state[:tables][table][:check_constraints] << build_check_constraint(expression, opts)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def apply_remove_check_constraints(state, scanner)
|
|
228
|
+
scanner.remove_check_constraints.each do |table, args|
|
|
229
|
+
next unless state[:tables][table]
|
|
230
|
+
|
|
231
|
+
remove_check_constraint_from_table(state[:tables][table], args)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def apply_change_table_blocks(state, scanner)
|
|
236
|
+
scanner.change_table_blocks.each do |table, block|
|
|
237
|
+
next unless state[:tables][table]
|
|
238
|
+
|
|
239
|
+
apply_block_table_changes(state[:tables][table], block)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def apply_block_table_changes(table_state, block)
|
|
244
|
+
scanner = MigrationDslScanner.new(block)
|
|
245
|
+
|
|
246
|
+
add_block_columns_to_table(table_state, scanner, block)
|
|
247
|
+
add_block_indexes_to_table(table_state, scanner, block)
|
|
248
|
+
apply_block_column_changes(table_state, scanner, block)
|
|
249
|
+
remove_block_columns_from_table(table_state, scanner, block)
|
|
250
|
+
apply_block_check_constraints(table_state, scanner, block)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def add_block_columns_to_table(table_state, scanner, block)
|
|
254
|
+
scanner.block_column_definitions(block).each do |definition|
|
|
255
|
+
if definition.first == :column
|
|
256
|
+
_, name, type, opts = definition
|
|
257
|
+
table_state[:columns] << build_column(name, type, opts)
|
|
258
|
+
next
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
type, name, opts = definition
|
|
262
|
+
if reference_type?(type)
|
|
263
|
+
build_reference_columns(name, opts).each { |column| table_state[:columns] << column }
|
|
264
|
+
next
|
|
265
|
+
end
|
|
266
|
+
table_state[:columns] << build_column(name, type, opts)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
return unless scanner.block_has_timestamps?(block)
|
|
270
|
+
|
|
271
|
+
table_state[:columns] << { name: "created_at", type: "datetime", null: false, default: nil }
|
|
272
|
+
table_state[:columns] << { name: "updated_at", type: "datetime", null: false, default: nil }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def add_block_indexes_to_table(table_state, scanner, block)
|
|
276
|
+
scanner.block_add_indexes(block).each do |cols_raw, opts|
|
|
277
|
+
table_state[:indexes] << build_index(cols_raw, opts)
|
|
278
|
+
end
|
|
279
|
+
scanner.block_remove_indexes(block).each do |args|
|
|
280
|
+
remove_index_from_table(table_state, args)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def apply_block_column_changes(table_state, scanner, block)
|
|
285
|
+
scanner.block_change_defaults(block).each do |col, options|
|
|
286
|
+
existing = table_state[:columns].find { |column| column[:name] == col }
|
|
287
|
+
next unless existing
|
|
288
|
+
|
|
289
|
+
existing[:default] = extract_default_value(options)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
scanner.block_change_nulls(block).each do |col, nullable, default_value|
|
|
293
|
+
existing = table_state[:columns].find { |column| column[:name] == col }
|
|
294
|
+
next unless existing
|
|
295
|
+
|
|
296
|
+
existing[:null] = nullable == "true"
|
|
297
|
+
existing[:default] = extract_default_value(default_value) if default_value
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
scanner.block_rename_indexes(block).each do |from, to|
|
|
301
|
+
from_name = clean_identifier(from)
|
|
302
|
+
to_name = clean_identifier(to)
|
|
303
|
+
idx = table_state[:indexes].find { |index| index[:name] == from_name }
|
|
304
|
+
idx[:name] = to_name if idx
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def remove_block_columns_from_table(table_state, scanner, block)
|
|
309
|
+
scanner.block_remove_columns(block).each do |col|
|
|
310
|
+
table_state[:columns].reject! { |c| c[:name] == col }
|
|
311
|
+
end
|
|
312
|
+
scanner.block_remove_columns_plural(block).each do |cols_raw|
|
|
313
|
+
extract_columns_list(cols_raw).each do |col|
|
|
314
|
+
table_state[:columns].reject! { |c| c[:name] == col }
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
scanner.block_remove_references(block).each do |ref, opts|
|
|
318
|
+
remove_reference_columns(table_state, ref, opts)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def apply_block_check_constraints(table_state, scanner, block)
|
|
323
|
+
scanner.block_add_check_constraints(block).each do |expression, opts|
|
|
324
|
+
table_state[:check_constraints] << build_check_constraint(expression, opts)
|
|
325
|
+
end
|
|
326
|
+
scanner.block_remove_check_constraints(block).each do |args|
|
|
327
|
+
remove_check_constraint_from_table(table_state, args)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def ensure_table(state, table)
|
|
332
|
+
state[:tables][table] ||= { columns: [], indexes: [], foreign_keys: [], check_constraints: [] }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def parse_block_columns(block)
|
|
336
|
+
columns = []
|
|
337
|
+
block.scan(/t\.column\s+[:"'](\w+)[:"']?,\s*:?(?:["'])?(\w+)(?:["'])?([^\n]*)/) do |name, type, opts|
|
|
338
|
+
columns << build_column(name, type, opts)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
block.scan(/t\.(\w+)\s+[:"'](\w+)[:"']?([^\n]*)/) do |type, name, opts|
|
|
342
|
+
next if %w[index timestamps].include?(type)
|
|
343
|
+
next if type == "column"
|
|
344
|
+
|
|
345
|
+
if reference_type?(type)
|
|
346
|
+
build_reference_columns(name, opts).each { |column| columns << column }
|
|
347
|
+
next
|
|
348
|
+
end
|
|
349
|
+
columns << build_column(name, type, opts)
|
|
350
|
+
end
|
|
351
|
+
if block =~ /t\.timestamps/
|
|
352
|
+
columns << { name: "created_at", type: "datetime", null: false, default: nil }
|
|
353
|
+
columns << { name: "updated_at", type: "datetime", null: false, default: nil }
|
|
354
|
+
end
|
|
355
|
+
columns
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def parse_block_indexes(block)
|
|
359
|
+
MigrationDslScanner.new(block).block_add_indexes(block).map do |cols_raw, opts|
|
|
360
|
+
build_index(cols_raw, opts)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def reference_type?(type)
|
|
365
|
+
%w[references belongs_to].include?(type)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def build_reference_columns(name, opts = "")
|
|
369
|
+
columns = [{
|
|
370
|
+
name: "#{name}_id",
|
|
371
|
+
type: reference_id_type(opts),
|
|
372
|
+
null: null_value_for_reference_options(opts),
|
|
373
|
+
default: nil
|
|
374
|
+
}]
|
|
375
|
+
if opts =~ /polymorphic:\s*true/
|
|
376
|
+
columns << {
|
|
377
|
+
name: "#{name}_type",
|
|
378
|
+
type: "string",
|
|
379
|
+
null: null_value_for_reference_options(opts),
|
|
380
|
+
default: nil
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
columns
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def remove_reference_columns(table_state, ref, opts = "")
|
|
387
|
+
table_state[:columns].reject! { |c| c[:name] == "#{ref}_id" }
|
|
388
|
+
return unless opts =~ /polymorphic:\s*true/
|
|
389
|
+
|
|
390
|
+
table_state[:columns].reject! { |c| c[:name] == "#{ref}_type" }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def reference_id_type(opts)
|
|
394
|
+
type_match = /type:\s*:?(?:["'])?(\w+)(?:["'])?/.match(opts)
|
|
395
|
+
type_match ? type_match[1] : "bigint"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def null_value_for_reference_options(opts)
|
|
399
|
+
null_match = /null:\s*(true|false)/.match(opts)
|
|
400
|
+
null_match ? null_match[1] == "true" : true
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def build_column(name, type, opts = "")
|
|
404
|
+
null_match = /null:\s*(true|false)/.match(opts)
|
|
405
|
+
default_match = /default:\s*([^,\n]+)/.match(opts)
|
|
406
|
+
limit_match = /limit:\s*(\d+)/.match(opts)
|
|
407
|
+
col = {
|
|
408
|
+
name: name,
|
|
409
|
+
type: type,
|
|
410
|
+
null: null_match ? null_match[1] == "true" : true,
|
|
411
|
+
default: default_match ? default_match[1].strip : nil
|
|
412
|
+
}
|
|
413
|
+
col[:limit] = limit_match[1].to_i if limit_match
|
|
414
|
+
col[:precision] = extract_numeric_option(opts, "precision")
|
|
415
|
+
col[:scale] = extract_numeric_option(opts, "scale")
|
|
416
|
+
col[:comment] = extract_string_option(opts, "comment")
|
|
417
|
+
col
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def build_index(cols_raw, opts)
|
|
421
|
+
name_match = /name:\s*"([^"]+)"/.match(opts)
|
|
422
|
+
unique = /unique:\s*true/.match?(opts)
|
|
423
|
+
cols = if cols_raw.start_with?("[")
|
|
424
|
+
cols_raw.scan(/[:"'](\w+)[:"']?/).flatten
|
|
425
|
+
else
|
|
426
|
+
[cols_raw.gsub(/['":,\s]/, "")]
|
|
427
|
+
end
|
|
428
|
+
{ name: name_match&.[](1), columns: cols, unique: unique }
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def build_foreign_key(to_table, opts)
|
|
432
|
+
{
|
|
433
|
+
to_table: to_table,
|
|
434
|
+
column: extract_string_option(opts, "column"),
|
|
435
|
+
primary_key: extract_string_option(opts, "primary_key"),
|
|
436
|
+
name: extract_string_option(opts, "name")
|
|
437
|
+
}
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def build_check_constraint(expression, opts)
|
|
441
|
+
{
|
|
442
|
+
expression: expression,
|
|
443
|
+
name: extract_string_option(opts, "name")
|
|
444
|
+
}
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def remove_index_from_table(table_state, args)
|
|
448
|
+
name_match = /name:\s*["']([^"']+)["']/.match(args)
|
|
449
|
+
return table_state[:indexes].reject! { |idx| idx[:name] == name_match[1] } if name_match
|
|
450
|
+
|
|
451
|
+
column_match = /column:\s*(\[.*?\]|[:"']\w+[:"']?)/.match(args)
|
|
452
|
+
cols_raw = column_match ? column_match[1] : args
|
|
453
|
+
columns = parse_columns_arg(cols_raw)
|
|
454
|
+
table_state[:indexes].reject! { |idx| idx[:columns] == columns }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def remove_foreign_key_from_table(table_state, args)
|
|
458
|
+
name_match = /name:\s*["']([^"']+)["']/.match(args)
|
|
459
|
+
if name_match
|
|
460
|
+
table_state[:foreign_keys].reject! { |fk| fk[:name] == name_match[1] }
|
|
461
|
+
return
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
to_table_match = /[:"'](\w+)[:"']?/.match(args)
|
|
465
|
+
return unless to_table_match
|
|
466
|
+
|
|
467
|
+
table_state[:foreign_keys].reject! { |fk| fk[:to_table] == to_table_match[1] }
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def remove_check_constraint_from_table(table_state, args)
|
|
471
|
+
name_match = /name:\s*["']([^"']+)["']/.match(args)
|
|
472
|
+
if name_match
|
|
473
|
+
table_state[:check_constraints].reject! { |cc| cc[:name] == name_match[1] }
|
|
474
|
+
return
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
expr_match = /["'](.+?)["']/.match(args)
|
|
478
|
+
return unless expr_match
|
|
479
|
+
|
|
480
|
+
table_state[:check_constraints].reject! { |cc| cc[:expression] == expr_match[1] }
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def clean_identifier(raw)
|
|
484
|
+
raw.to_s.gsub(/['":,\s]/, "")
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def extract_default_value(raw)
|
|
488
|
+
return nil unless raw
|
|
489
|
+
|
|
490
|
+
to_match = /to:\s*([^,}\n]+)/.match(raw)
|
|
491
|
+
value = to_match ? to_match[1].strip : raw.strip
|
|
492
|
+
value.gsub(/\A["']|["']\z/, "")
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def extract_string_option(raw, key)
|
|
496
|
+
return nil unless raw
|
|
497
|
+
|
|
498
|
+
match = /#{key}:\s*(?:["']([^"']+)["']|:(\w+)|([^,\n]+))/.match(raw)
|
|
499
|
+
return nil unless match
|
|
500
|
+
|
|
501
|
+
(match[1] || match[2] || match[3]).to_s.strip
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def extract_numeric_option(raw, key)
|
|
505
|
+
return nil unless raw
|
|
506
|
+
|
|
507
|
+
match = /#{key}:\s*(\d+)/.match(raw)
|
|
508
|
+
match ? match[1].to_i : nil
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def extract_columns_list(raw)
|
|
512
|
+
columns_part = raw.split(/,\s*\w+:\s*/).first.to_s
|
|
513
|
+
columns_part.scan(/[:"'](\w+)[:"']?/).flatten
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def parse_columns_arg(cols_raw)
|
|
517
|
+
return cols_raw.scan(/[:"'](\w+)[:"']?/).flatten if cols_raw.start_with?("[")
|
|
518
|
+
|
|
519
|
+
[cols_raw.gsub(/['":,\s]/, "")]
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def calculate_diff(before, after)
|
|
523
|
+
{
|
|
524
|
+
added_tables: after[:tables].keys - before[:tables].keys,
|
|
525
|
+
removed_tables: before[:tables].keys - after[:tables].keys,
|
|
526
|
+
modified_tables: modified_tables_diff(before, after)
|
|
527
|
+
}
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def modified_tables_diff(before, after)
|
|
531
|
+
common = before[:tables].keys & after[:tables].keys
|
|
532
|
+
common.each_with_object({}) do |table, result|
|
|
533
|
+
before_cols = before[:tables][table][:columns].map { |c| c[:name] }
|
|
534
|
+
after_cols = after[:tables][table][:columns].map { |c| c[:name] }
|
|
535
|
+
added = after_cols - before_cols
|
|
536
|
+
removed = before_cols - after_cols
|
|
537
|
+
result[table] = { added_columns: added, removed_columns: removed } if added.any? || removed.any?
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "migration_dsl_scanner"
|
|
4
|
+
|
|
5
|
+
module Migflow
|
|
6
|
+
module Services
|
|
7
|
+
class TouchedTablesFromMigration
|
|
8
|
+
SCANNER_FIRST_COLUMN_TABLE_METHODS = %i[
|
|
9
|
+
add_columns
|
|
10
|
+
remove_column
|
|
11
|
+
remove_columns
|
|
12
|
+
rename_columns
|
|
13
|
+
change_columns
|
|
14
|
+
change_column_defaults
|
|
15
|
+
change_column_nulls
|
|
16
|
+
change_column_comments
|
|
17
|
+
add_references
|
|
18
|
+
remove_references
|
|
19
|
+
add_indexes
|
|
20
|
+
remove_indexes
|
|
21
|
+
rename_indexes
|
|
22
|
+
add_foreign_keys
|
|
23
|
+
remove_foreign_keys
|
|
24
|
+
add_check_constraints
|
|
25
|
+
remove_check_constraints
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def self.call(raw_content:)
|
|
29
|
+
new(raw_content: raw_content).call
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(raw_content:)
|
|
33
|
+
@raw_content = raw_content.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
scanner = MigrationDslScanner.new(@raw_content)
|
|
38
|
+
names = base_table_names(scanner) + scanner_method_table_names(scanner)
|
|
39
|
+
names.map(&:to_s).reject(&:empty?).uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def base_table_names(scanner)
|
|
45
|
+
names = []
|
|
46
|
+
names.concat(scanner.create_table_blocks.map(&:first))
|
|
47
|
+
names.concat(scanner.change_table_blocks.map(&:first))
|
|
48
|
+
names.concat(scanner.drop_tables)
|
|
49
|
+
names.concat(scanner.rename_tables.flatten)
|
|
50
|
+
names
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def scanner_method_table_names(scanner)
|
|
54
|
+
SCANNER_FIRST_COLUMN_TABLE_METHODS.flat_map do |method_name|
|
|
55
|
+
scanner.public_send(method_name).map(&:first)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/migflow.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "migflow/version"
|
|
4
|
+
require "migflow/engine"
|
|
5
|
+
require "migflow/configuration"
|
|
6
|
+
require "migflow/parsers/migration_parser"
|
|
7
|
+
require "migflow/parsers/schema_parser"
|
|
8
|
+
require "migflow/analyzers/audit_analyzer"
|
|
9
|
+
require "migflow/models/migration_snapshot"
|
|
10
|
+
require "migflow/models/schema_diff"
|
|
11
|
+
require "migflow/models/warning"
|
|
12
|
+
require "migflow/services/schema_builder"
|
|
13
|
+
require "migflow/services/diff_builder"
|
|
14
|
+
require "migflow/services/migration_dsl_scanner"
|
|
15
|
+
require "migflow/services/migration_summary_builder"
|
|
16
|
+
require "migflow/services/snapshot_builder"
|
|
17
|
+
require "migflow/services/schema_patch_builder"
|
|
18
|
+
require "migflow/services/touched_tables_from_migration"
|
|
19
|
+
require "migflow/services/scoped_migration_warnings"
|
|
20
|
+
require "migflow/services/risk_scorer"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "migflow/services/report_generator"
|
|
4
|
+
require "migflow/reporters"
|
|
5
|
+
|
|
6
|
+
namespace :migflow do
|
|
7
|
+
desc "Analyze migrations and output a report. Options: FORMAT=markdown|json, FAIL_ON=<level|score>, OUTPUT=<path>"
|
|
8
|
+
task report: :environment do
|
|
9
|
+
format = ENV.fetch("FORMAT", "markdown")
|
|
10
|
+
fail_on = ENV.fetch("FAIL_ON", nil)
|
|
11
|
+
output = ENV.fetch("OUTPUT", nil)
|
|
12
|
+
|
|
13
|
+
report = Migflow::Services::ReportGenerator.new.call(
|
|
14
|
+
migrations_path: Migflow.configuration.resolved_migrations_path
|
|
15
|
+
)
|
|
16
|
+
rendered = Migflow::Reporters.for(format).render(report)
|
|
17
|
+
|
|
18
|
+
if output
|
|
19
|
+
File.write(output, rendered)
|
|
20
|
+
$stdout.puts "Report written to #{output}"
|
|
21
|
+
else
|
|
22
|
+
$stdout.puts rendered
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
threshold = Migflow::Reporters.resolve_threshold(fail_on)
|
|
26
|
+
if threshold && report[:summary][:highest_risk_score] >= threshold
|
|
27
|
+
warn "migflow: gate failed — highest risk score #{report[:summary][:highest_risk_score]} >= #{threshold}"
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/sig/migflow.rbs
ADDED