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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +44 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +45 -0
  5. data/CLAUDE.md +124 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/CONTRIBUTING.md +157 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +218 -0
  10. data/Rakefile +11 -0
  11. data/SECURITY.md +27 -0
  12. data/app/assets/migflow/app.css +1 -0
  13. data/app/assets/migflow/app.js +28 -0
  14. data/app/assets/migflow/index.html +14 -0
  15. data/app/assets/migflow/vite.svg +1 -0
  16. data/app/controllers/migflow/api/diff_controller.rb +73 -0
  17. data/app/controllers/migflow/api/migrations_controller.rb +97 -0
  18. data/app/controllers/migflow/application_controller.rb +62 -0
  19. data/app/views/migflow/application/index.html.erb +16 -0
  20. data/config/routes.rb +10 -0
  21. data/docs/architecture.md +130 -0
  22. data/lib/migflow/analyzers/audit_analyzer.rb +58 -0
  23. data/lib/migflow/analyzers/rules/base_rule.rb +32 -0
  24. data/lib/migflow/analyzers/rules/dangerous_migration_rule.rb +44 -0
  25. data/lib/migflow/analyzers/rules/missing_foreign_key_rule.rb +30 -0
  26. data/lib/migflow/analyzers/rules/missing_index_rule.rb +32 -0
  27. data/lib/migflow/analyzers/rules/missing_timestamps_rule.rb +38 -0
  28. data/lib/migflow/analyzers/rules/null_column_without_default_rule.rb +46 -0
  29. data/lib/migflow/analyzers/rules/string_without_limit_rule.rb +28 -0
  30. data/lib/migflow/app/assets/migflow/app.css +1 -0
  31. data/lib/migflow/app/assets/migflow/app.js +17 -0
  32. data/lib/migflow/app/assets/migflow/index.html +14 -0
  33. data/lib/migflow/app/assets/migflow/vite.svg +1 -0
  34. data/lib/migflow/configuration.rb +36 -0
  35. data/lib/migflow/engine.rb +14 -0
  36. data/lib/migflow/models/migration_snapshot.rb +15 -0
  37. data/lib/migflow/models/schema_diff.rb +9 -0
  38. data/lib/migflow/models/warning.rb +7 -0
  39. data/lib/migflow/parsers/migration_parser.rb +52 -0
  40. data/lib/migflow/parsers/schema_parser.rb +105 -0
  41. data/lib/migflow/reporters/json_reporter.rb +13 -0
  42. data/lib/migflow/reporters/markdown_reporter.rb +58 -0
  43. data/lib/migflow/reporters.rb +38 -0
  44. data/lib/migflow/services/diff_builder.rb +77 -0
  45. data/lib/migflow/services/migration_dsl_scanner.rb +161 -0
  46. data/lib/migflow/services/migration_summary_builder.rb +43 -0
  47. data/lib/migflow/services/report_generator.rb +76 -0
  48. data/lib/migflow/services/risk_scorer.rb +38 -0
  49. data/lib/migflow/services/schema_builder.rb +25 -0
  50. data/lib/migflow/services/schema_patch_builder.rb +237 -0
  51. data/lib/migflow/services/scoped_migration_warnings.rb +93 -0
  52. data/lib/migflow/services/snapshot_builder.rb +542 -0
  53. data/lib/migflow/services/touched_tables_from_migration.rb +60 -0
  54. data/lib/migflow/version.rb +5 -0
  55. data/lib/migflow.rb +20 -0
  56. data/lib/tasks/migflow.rake +31 -0
  57. data/sig/migflow.rbs +3 -0
  58. metadata +124 -0
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ <script type="module" crossorigin src="/app.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/app.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ class << self
5
+ def configuration
6
+ @configuration ||= Configuration.new
7
+ end
8
+
9
+ def configure
10
+ yield configuration
11
+ end
12
+ end
13
+
14
+ class Configuration
15
+ attr_accessor :migrations_path, :schema_path, :enabled_rules, :authentication_hook, :expose_raw_content,
16
+ :parent_controller, :unauthenticated_redirect
17
+
18
+ def initialize
19
+ @migrations_path = nil
20
+ @schema_path = nil
21
+ @enabled_rules = :all
22
+ @authentication_hook = nil
23
+ @expose_raw_content = true
24
+ @parent_controller = "ActionController::Base"
25
+ @unauthenticated_redirect = nil
26
+ end
27
+
28
+ def resolved_migrations_path
29
+ migrations_path || Rails.root.join("db/migrate")
30
+ end
31
+
32
+ def resolved_schema_path
33
+ schema_path || Rails.root.join("db/schema.rb")
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Migflow
6
+
7
+ initializer "migflow.assets" do |app|
8
+ if app.config.respond_to?(:assets)
9
+ app.config.assets.paths << root.join("app/assets")
10
+ app.config.assets.precompile += %w[migflow/app.js migflow/app.css]
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Models
5
+ MigrationSnapshot = Data.define(:version, :name, :tables, :raw_content) do
6
+ def table_names
7
+ tables.keys.sort
8
+ end
9
+
10
+ def find_table(name)
11
+ tables[name]
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Models
5
+ SchemaDiff = Data.define(:from_version, :to_version, :changes)
6
+
7
+ Change = Data.define(:type, :table, :detail)
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Models
5
+ Warning = Data.define(:rule, :severity, :table, :column, :message)
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Parsers
5
+ class MigrationParser
6
+ FILENAME_PATTERN = /\A(\d+)_(.+)\.rb\z/
7
+
8
+ def self.call(migrations_path:)
9
+ new(migrations_path: migrations_path).parse
10
+ end
11
+
12
+ def initialize(migrations_path:)
13
+ @migrations_path = Pathname.new(migrations_path)
14
+ end
15
+
16
+ def parse
17
+ migration_files
18
+ .map { |file| build_entry(file) }
19
+ .compact
20
+ .sort_by { |entry| entry[:version] }
21
+ end
22
+
23
+ private
24
+
25
+ def migration_files
26
+ return [] unless @migrations_path.directory?
27
+
28
+ @migrations_path.glob("*.rb")
29
+ end
30
+
31
+ def build_entry(file)
32
+ match = FILENAME_PATTERN.match(file.basename.to_s)
33
+ return nil unless match
34
+
35
+ version = match[1]
36
+ raw_name = match[2]
37
+
38
+ {
39
+ version: version,
40
+ name: humanize(raw_name),
41
+ filename: file.basename.to_s,
42
+ filepath: file,
43
+ raw_content: file.read
44
+ }
45
+ end
46
+
47
+ def humanize(raw_name)
48
+ raw_name.gsub("_", " ").capitalize
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Parsers
5
+ class SchemaParser
6
+ VERSION_PATTERN = /ActiveRecord::Schema(?:\[[\d.]+\])?\.define\(version:\s*([\d_]+)\)/
7
+ CREATE_TABLE_PATTERN = /create_table\s+"([^"]+)"/
8
+ COLUMN_PATTERN = /t\.(\w+)\s+"([^"]+)"(.*)/
9
+ INDEX_PATTERN = /add_index\s+"([^"]+)",\s+(\[.*?\]|"[^"]+"|'[^']+')(.*)/
10
+ UNIQUE_PATTERN = /unique:\s*true/
11
+ NULL_PATTERN = /null:\s*(true|false)/
12
+ DEFAULT_PATTERN = /default:\s*([^,\n]+)/
13
+ LIMIT_PATTERN = /limit:\s*(\d+)/
14
+
15
+ def self.call(schema_path:)
16
+ new(schema_path: schema_path).parse
17
+ end
18
+
19
+ def initialize(schema_path:)
20
+ @schema_path = Pathname.new(schema_path)
21
+ end
22
+
23
+ def parse
24
+ content = @schema_path.read
25
+ {
26
+ version: extract_version(content),
27
+ tables: extract_tables(content)
28
+ }
29
+ end
30
+
31
+ private
32
+
33
+ def extract_version(content)
34
+ match = VERSION_PATTERN.match(content)
35
+ match ? match[1].delete("_") : nil
36
+ end
37
+
38
+ def extract_tables(content)
39
+ tables = {}
40
+ table_blocks(content).each do |table_name, block|
41
+ tables[table_name] = {
42
+ columns: parse_columns(block),
43
+ indexes: []
44
+ }
45
+ end
46
+ parse_indexes(content, tables)
47
+ tables
48
+ end
49
+
50
+ def table_blocks(content)
51
+ blocks = {}
52
+ content.scan(/create_table\s+"([^"]+)"[^\n]*\n(.*?)end/m) do |name, body|
53
+ blocks[name] = body
54
+ end
55
+ blocks
56
+ end
57
+
58
+ def parse_columns(block)
59
+ block.scan(COLUMN_PATTERN).map do |type, name, options|
60
+ build_column(name, type, options)
61
+ end
62
+ end
63
+
64
+ def build_column(name, type, options)
65
+ null_match = NULL_PATTERN.match(options)
66
+ default_match = DEFAULT_PATTERN.match(options)
67
+ limit_match = LIMIT_PATTERN.match(options)
68
+
69
+ column = {
70
+ name: name,
71
+ type: type,
72
+ null: null_match ? null_match[1] == "true" : true,
73
+ default: default_match ? default_match[1].strip : nil
74
+ }
75
+ column[:limit] = limit_match[1].to_i if limit_match
76
+ column
77
+ end
78
+
79
+ def parse_indexes(content, tables)
80
+ content.scan(INDEX_PATTERN) do |table, columns_raw, options|
81
+ next unless tables.key?(table)
82
+
83
+ tables[table][:indexes] << build_index(columns_raw, options)
84
+ end
85
+ end
86
+
87
+ def build_index(columns_raw, options)
88
+ name_match = /name:\s*"([^"]+)"/.match(options)
89
+ {
90
+ name: name_match ? name_match[1] : nil,
91
+ columns: parse_index_columns(columns_raw),
92
+ unique: UNIQUE_PATTERN.match?(options)
93
+ }
94
+ end
95
+
96
+ def parse_index_columns(raw)
97
+ if raw.start_with?("[")
98
+ raw.scan(/"([^"]+)"/).flatten
99
+ else
100
+ [raw.delete("\"'")]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Migflow
6
+ module Reporters
7
+ class JsonReporter
8
+ def render(report)
9
+ JSON.pretty_generate(report)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Reporters
5
+ class MarkdownReporter
6
+ LEVEL_EMOJI = {
7
+ "high" => "🔴",
8
+ "medium" => "🟡",
9
+ "low" => "🟢",
10
+ "safe" => "⚪"
11
+ }.freeze
12
+
13
+ def render(report)
14
+ lines = []
15
+ lines << "## Migflow Analysis Report\n"
16
+ lines << build_table(report[:migrations])
17
+ lines << ""
18
+ lines << build_footer(report[:summary])
19
+ lines.join("\n")
20
+ end
21
+
22
+ private
23
+
24
+ def build_table(migrations)
25
+ rows = migrations.map do |m|
26
+ errors = m[:warnings].count { |w| w[:severity] == "error" }
27
+ warnings = m[:warnings].count { |w| w[:severity] == "warning" }
28
+ emoji = LEVEL_EMOJI.fetch(m[:risk_level], "⚪")
29
+ label = "#{emoji} #{m[:risk_level].upcase}"
30
+ warning_summary = warning_cell(errors, warnings)
31
+
32
+ "| #{m[:version]} #{m[:name]} | #{m[:risk_score]} | #{label} | #{warning_summary} |"
33
+ end
34
+
35
+ [
36
+ "| Migration | Risk Score | Level | Warnings |",
37
+ "|-----------|------------|-------|----------|",
38
+ *rows
39
+ ].join("\n")
40
+ end
41
+
42
+ def warning_cell(errors, warnings)
43
+ parts = []
44
+ parts << "#{errors} #{"error".then { |s| errors == 1 ? s : "#{s}s" }}" if errors.positive?
45
+ parts << "#{warnings} #{"warning".then { |s| warnings == 1 ? s : "#{s}s" }}" if warnings.positive?
46
+ parts.empty? ? "none" : parts.join(", ")
47
+ end
48
+
49
+ def build_footer(summary)
50
+ [
51
+ "**Migrations analyzed:** #{summary[:total_migrations]}",
52
+ "**With issues:** #{summary[:migrations_with_warnings]}",
53
+ "**Highest score:** #{summary[:highest_risk_score]}"
54
+ ].join(" | ")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reporters/json_reporter"
4
+ require_relative "reporters/markdown_reporter"
5
+ require_relative "services/risk_scorer"
6
+
7
+ module Migflow
8
+ module Reporters
9
+ # Maps level names to the minimum score for that level: { "high" => 71, "medium" => 31, "low" => 1 }
10
+ LEVEL_THRESHOLDS = Migflow::Services::RiskScorer::LEVELS
11
+ .reject { |l| l[:level] == "safe" }
12
+ .to_h { |l| [l[:level], l[:min]] }
13
+ .freeze
14
+
15
+ def self.for(format)
16
+ case format.to_sym
17
+ when :json then JsonReporter.new
18
+ when :markdown then MarkdownReporter.new
19
+ else raise ArgumentError, "Unknown format: #{format}. Use 'json' or 'markdown'."
20
+ end
21
+ end
22
+
23
+ # Resolves FAIL_ON value (level name or numeric string) to a minimum score threshold.
24
+ # Returns nil if FAIL_ON is blank.
25
+ def self.resolve_threshold(fail_on)
26
+ return nil if fail_on.nil? || fail_on.strip.empty?
27
+
28
+ if fail_on.match?(/\A\d+\z/)
29
+ Integer(fail_on)
30
+ else
31
+ LEVEL_THRESHOLDS.fetch(fail_on.downcase) do
32
+ raise ArgumentError,
33
+ "Unknown FAIL_ON level '#{fail_on}'. Use low/medium/high or a numeric score."
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Services
5
+ class DiffBuilder
6
+ def self.call(from_tables:, to_tables:, from_version:, to_version:)
7
+ new(from_tables: from_tables, to_tables: to_tables,
8
+ from_version: from_version, to_version: to_version).build
9
+ end
10
+
11
+ def initialize(from_tables:, to_tables:, from_version:, to_version:)
12
+ @from_tables = from_tables
13
+ @to_tables = to_tables
14
+ @from_version = from_version
15
+ @to_version = to_version
16
+ end
17
+
18
+ def build
19
+ Models::SchemaDiff.new(
20
+ from_version: @from_version,
21
+ to_version: @to_version,
22
+ changes: table_changes + column_changes + index_changes
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def table_changes
29
+ added = (@to_tables.keys - @from_tables.keys).map { |t| change(:added_table, t, "added table #{t}") }
30
+ removed = (@from_tables.keys - @to_tables.keys).map { |t| change(:removed_table, t, "removed table #{t}") }
31
+ added + removed
32
+ end
33
+
34
+ def column_changes
35
+ common_tables.flat_map do |table|
36
+ from_cols = column_map(@from_tables[table])
37
+ to_cols = column_map(@to_tables[table])
38
+
39
+ added = (to_cols.keys - from_cols.keys).map do |c|
40
+ change(:added_column, table, "added #{c} (#{to_cols[c][:type]})")
41
+ end
42
+ removed = (from_cols.keys - to_cols.keys).map do |c|
43
+ change(:removed_column, table, "removed #{c} (#{from_cols[c][:type]})")
44
+ end
45
+ added + removed
46
+ end
47
+ end
48
+
49
+ def index_changes
50
+ common_tables.flat_map do |table|
51
+ from_idxs = index_map(@from_tables[table])
52
+ to_idxs = index_map(@to_tables[table])
53
+
54
+ added = (to_idxs.keys - from_idxs.keys).map { |i| change(:added_index, table, "added index #{i}") }
55
+ removed = (from_idxs.keys - to_idxs.keys).map { |i| change(:removed_index, table, "removed index #{i}") }
56
+ added + removed
57
+ end
58
+ end
59
+
60
+ def common_tables
61
+ @from_tables.keys & @to_tables.keys
62
+ end
63
+
64
+ def column_map(table)
65
+ table[:columns].index_by { |c| c[:name] }
66
+ end
67
+
68
+ def index_map(table)
69
+ table[:indexes].index_by { |i| i[:name] || i[:columns].join("_") }
70
+ end
71
+
72
+ def change(type, table, detail)
73
+ Models::Change.new(type: type, table: table, detail: detail)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Migflow
4
+ module Services
5
+ class MigrationDslScanner
6
+ BLOCK_NON_COLUMNS = %w[
7
+ column index timestamps remove remove_columns rename change
8
+ change_default change_null remove_references remove_timestamps
9
+ remove_index rename_index check_constraint remove_check_constraint
10
+ ].freeze
11
+
12
+ def initialize(content)
13
+ @content = content
14
+ end
15
+
16
+ def create_table_blocks
17
+ @content.scan(/create_table\s+[:"'](\w+)[:"']?[^\n]*\n(.*?)\n\s*end\b/m)
18
+ end
19
+
20
+ def change_table_blocks
21
+ @content.scan(/change_table\s+[:"'](\w+)[:"']?[^\n]*\n(.*?)\n\s*end\b/m)
22
+ end
23
+
24
+ def drop_tables
25
+ @content.scan(/drop_table\s+[:"'](\w+)/).flatten
26
+ end
27
+
28
+ def add_columns
29
+ @content.scan(/add_column\s*\(?\s*[:"'](\w+)[:"']?\s*,\s*[:"'](\w+)[:"']?\s*,\s*[:"'](\w+)([^\n]*)/)
30
+ end
31
+
32
+ def remove_column
33
+ @content.scan(/remove_column\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)/)
34
+ end
35
+
36
+ def remove_columns
37
+ @content.scan(/remove_columns\s+[:"'](\w+)[:"']?,\s*([^\n]+)/)
38
+ end
39
+
40
+ def add_references
41
+ @content.scan(/add_(?:reference|belongs_to)\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?([^\n]*)/)
42
+ end
43
+
44
+ def remove_references
45
+ @content.scan(/remove_(?:reference|references|belongs_to)\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?([^\n]*)/)
46
+ end
47
+
48
+ def rename_columns
49
+ @content.scan(/rename_column\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?,\s*[:"'](\w+)/)
50
+ end
51
+
52
+ def rename_tables
53
+ @content.scan(/rename_table\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)/)
54
+ end
55
+
56
+ def rename_indexes
57
+ @content.scan(/rename_index\s+[:"'](\w+)[:"']?,\s*([:"']\w+[:"']?),\s*([:"']\w+[:"']?)/)
58
+ end
59
+
60
+ def change_columns
61
+ @content.scan(/change_column\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?,\s*[:"'](\w+)/)
62
+ end
63
+
64
+ def change_column_defaults
65
+ @content.scan(/change_column_default\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?,\s*([^\n]+)/)
66
+ end
67
+
68
+ def change_column_nulls
69
+ @content.scan(/change_column_null\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?,\s*(true|false)(?:,\s*([^\n]+))?/)
70
+ end
71
+
72
+ def change_column_comments
73
+ @content.scan(/change_column_comment\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?,\s*([^\n]+)/)
74
+ end
75
+
76
+ def add_indexes
77
+ @content.scan(/add_index\s+[:"'](\w+)[:"']?,\s*(\[.*?\]|[:"']\w+[:"']?)([^\n]*)/)
78
+ end
79
+
80
+ def remove_indexes
81
+ @content.scan(/remove_index\s+[:"'](\w+)[:"']?,\s*([^\n]+)/)
82
+ end
83
+
84
+ def add_foreign_keys
85
+ @content.scan(/add_foreign_key\s+[:"'](\w+)[:"']?,\s*[:"'](\w+)[:"']?([^\n]*)/)
86
+ end
87
+
88
+ def remove_foreign_keys
89
+ @content.scan(/remove_foreign_key\s+[:"'](\w+)[:"']?,\s*([^\n]+)/)
90
+ end
91
+
92
+ def add_check_constraints
93
+ @content.scan(/add_check_constraint\s+[:"'](\w+)[:"']?,\s*["'](.+?)["']([^\n]*)/)
94
+ end
95
+
96
+ def remove_check_constraints
97
+ @content.scan(/remove_check_constraint\s+[:"'](\w+)[:"']?,\s*([^\n]+)/)
98
+ end
99
+
100
+ def block_column_definitions(block)
101
+ definitions = []
102
+
103
+ block.scan(/t\.column\s+[:"'](\w+)[:"']?,\s*:?(?:["'])?(\w+)(?:["'])?([^\n]*)/) do |name, type, opts|
104
+ definitions << [:column, name, type, opts]
105
+ end
106
+
107
+ block.scan(/t\.(\w+)\s+[:"'](\w+)[:"']?([^\n]*)/) do |type, name, opts|
108
+ next if BLOCK_NON_COLUMNS.include?(type)
109
+
110
+ definitions << [type, name, opts]
111
+ end
112
+
113
+ definitions
114
+ end
115
+
116
+ def block_has_timestamps?(block)
117
+ block.match?(/t\.timestamps/)
118
+ end
119
+
120
+ def block_add_indexes(block)
121
+ block.scan(/t\.index\s+(\[.*?\]|[:"']\w+[:"']?)([^\n]*)/)
122
+ end
123
+
124
+ def block_remove_indexes(block)
125
+ block.scan(/t\.remove_index\s+([^\n]+)/).flatten
126
+ end
127
+
128
+ def block_remove_columns(block)
129
+ block.scan(/t\.remove\s+[:"'](\w+)/).flatten
130
+ end
131
+
132
+ def block_remove_columns_plural(block)
133
+ block.scan(/t\.remove_columns\s+([^\n]+)/).flatten
134
+ end
135
+
136
+ def block_remove_references(block)
137
+ block.scan(/t\.remove_(?:reference|references|belongs_to)\s+[:"'](\w+)[:"']?([^\n]*)/)
138
+ end
139
+
140
+ def block_change_defaults(block)
141
+ block.scan(/t\.change_default\s+[:"'](\w+)[:"']?,\s*([^\n]+)/)
142
+ end
143
+
144
+ def block_change_nulls(block)
145
+ block.scan(/t\.change_null\s+[:"'](\w+)[:"']?,\s*(true|false)(?:,\s*([^\n]+))?/)
146
+ end
147
+
148
+ def block_rename_indexes(block)
149
+ block.scan(/t\.rename_index\s+([:"']\w+[:"']?),\s*([:"']\w+[:"']?)/)
150
+ end
151
+
152
+ def block_add_check_constraints(block)
153
+ block.scan(/t\.check_constraint\s+["'](.+?)["']([^\n]*)/)
154
+ end
155
+
156
+ def block_remove_check_constraints(block)
157
+ block.scan(/t\.remove_check_constraint\s+([^\n]+)/).flatten
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "migration_dsl_scanner"
4
+
5
+ module Migflow
6
+ module Services
7
+ class MigrationSummaryBuilder
8
+ def self.call(raw_content:, version:)
9
+ new(raw_content: raw_content, version: version).call
10
+ end
11
+
12
+ def initialize(raw_content:, version:)
13
+ @raw_content = raw_content.to_s
14
+ @version = version
15
+ end
16
+
17
+ def call
18
+ scanner = MigrationDslScanner.new(@raw_content)
19
+
20
+ created_tables = scanner.create_table_blocks.map(&:first)
21
+ return "Created table #{created_tables.join(", ")}" if created_tables.any?
22
+
23
+ dropped_tables = scanner.drop_tables
24
+ return "Dropped table #{dropped_tables.join(", ")}" if dropped_tables.any?
25
+
26
+ added_details = added_column_details(scanner) + added_reference_details(scanner)
27
+ return "Added #{added_details.join(", ")}" if added_details.any?
28
+
29
+ "Migration #{@version}"
30
+ end
31
+
32
+ private
33
+
34
+ def added_column_details(scanner)
35
+ scanner.add_columns.map { |table, column, *_| "#{column} to #{table}" }
36
+ end
37
+
38
+ def added_reference_details(scanner)
39
+ scanner.add_references.map { |table, reference, *_| "#{reference}_id to #{table}" }
40
+ end
41
+ end
42
+ end
43
+ end