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,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,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,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
|