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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
module Api
|
|
5
|
+
class DiffController < ApplicationController
|
|
6
|
+
def show
|
|
7
|
+
return render_error("params 'from' and 'to' are required") unless from_version && to_version
|
|
8
|
+
|
|
9
|
+
migrations = Parsers::MigrationParser.call(migrations_path: migrations_path)
|
|
10
|
+
from_data = find_migration(migrations, from_version)
|
|
11
|
+
to_data = find_migration(migrations, to_version)
|
|
12
|
+
|
|
13
|
+
return render_error("One or both migrations not found", status: :not_found) unless from_data && to_data
|
|
14
|
+
|
|
15
|
+
result = build_diff(migrations, from_data, to_data)
|
|
16
|
+
render_json(diff: serialize_diff(result))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def from_version = params[:from]
|
|
22
|
+
def to_version = params[:to]
|
|
23
|
+
|
|
24
|
+
def find_migration(migrations, version)
|
|
25
|
+
migrations.find { |m| m[:version] == version }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_diff(migrations, from_data, to_data)
|
|
29
|
+
ordered = migrations.sort_by { |migration| migration[:version] }
|
|
30
|
+
from_idx = ordered.find_index { |migration| migration[:version] == from_data[:version] }
|
|
31
|
+
previous_from = from_idx&.positive? ? ordered[from_idx - 1] : nil
|
|
32
|
+
|
|
33
|
+
from_tables = if previous_from
|
|
34
|
+
previous_result = Services::SnapshotBuilder.call(migrations: migrations,
|
|
35
|
+
up_to_version: previous_from[:version])
|
|
36
|
+
previous_result[:schema_after][:tables]
|
|
37
|
+
else
|
|
38
|
+
{}
|
|
39
|
+
end
|
|
40
|
+
to_result = Services::SnapshotBuilder.call(migrations: migrations, up_to_version: to_data[:version])
|
|
41
|
+
|
|
42
|
+
diff = Services::DiffBuilder.call(
|
|
43
|
+
from_tables: from_tables,
|
|
44
|
+
to_tables: to_result[:schema_after][:tables],
|
|
45
|
+
from_version: from_data[:version],
|
|
46
|
+
to_version: to_data[:version]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
diff: diff,
|
|
51
|
+
from_tables: from_tables,
|
|
52
|
+
to_tables: to_result[:schema_after][:tables]
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def serialize_diff(result)
|
|
57
|
+
diff = result[:diff]
|
|
58
|
+
changed_tables = diff.changes.map(&:table).uniq
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
from_version: diff.from_version,
|
|
62
|
+
to_version: diff.to_version,
|
|
63
|
+
changes: diff.changes.map { |c| { type: c.type, table: c.table, detail: c.detail } },
|
|
64
|
+
**serialize_schema_patches(
|
|
65
|
+
from_tables: result[:from_tables],
|
|
66
|
+
to_tables: result[:to_tables],
|
|
67
|
+
changed_tables: changed_tables
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
module Api
|
|
5
|
+
class MigrationsController < ApplicationController
|
|
6
|
+
def index
|
|
7
|
+
migrations = Parsers::MigrationParser.call(migrations_path: migrations_path)
|
|
8
|
+
render_json(migrations: migrations.map { |m| serialize_summary(m, migrations) })
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
migrations = Parsers::MigrationParser.call(migrations_path: migrations_path)
|
|
13
|
+
migration = migrations.find { |m| m[:version] == params[:id] }
|
|
14
|
+
|
|
15
|
+
return render_error("Migration not found", status: :not_found) unless migration
|
|
16
|
+
|
|
17
|
+
result = Services::SnapshotBuilder.call(migrations: migrations, up_to_version: params[:id])
|
|
18
|
+
snapshot = snapshot_model_from(result[:schema_after], migration[:version])
|
|
19
|
+
warnings = Services::ScopedMigrationWarnings.call(snapshot: snapshot, migration: migration, diff: result[:diff])
|
|
20
|
+
risk = Services::RiskScorer.new.call(warnings)
|
|
21
|
+
|
|
22
|
+
render_json(migration: serialize_detail(migration, result, warnings, risk))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def snapshot_model_from(schema_after, version)
|
|
28
|
+
Models::MigrationSnapshot.new(
|
|
29
|
+
version: version,
|
|
30
|
+
name: "Historical schema",
|
|
31
|
+
tables: schema_after[:tables],
|
|
32
|
+
raw_content: ""
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def serialize_summary(migration, all_migrations)
|
|
37
|
+
risk = compute_risk(migration, all_migrations)
|
|
38
|
+
{
|
|
39
|
+
version: migration[:version],
|
|
40
|
+
name: migration[:name],
|
|
41
|
+
filename: migration[:filename],
|
|
42
|
+
summary: Services::MigrationSummaryBuilder.call(
|
|
43
|
+
raw_content: migration[:raw_content],
|
|
44
|
+
version: migration[:version]
|
|
45
|
+
),
|
|
46
|
+
risk_score: risk[:score],
|
|
47
|
+
risk_level: risk[:level]
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def serialize_detail(migration, snapshot_result, warnings, risk)
|
|
52
|
+
schema_before_tables = snapshot_result[:schema_before][:tables]
|
|
53
|
+
schema_after_tables = snapshot_result[:schema_after][:tables]
|
|
54
|
+
diff = snapshot_result[:diff]
|
|
55
|
+
changed_tables = (
|
|
56
|
+
diff[:added_tables] +
|
|
57
|
+
diff[:removed_tables] +
|
|
58
|
+
diff[:modified_tables].keys
|
|
59
|
+
).uniq
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
version: migration[:version],
|
|
63
|
+
name: migration[:name],
|
|
64
|
+
raw_content: Migflow.configuration.expose_raw_content ? migration[:raw_content] : nil,
|
|
65
|
+
schema_after: { tables: schema_after_tables },
|
|
66
|
+
diff: diff,
|
|
67
|
+
**serialize_schema_patches(
|
|
68
|
+
from_tables: schema_before_tables,
|
|
69
|
+
to_tables: schema_after_tables,
|
|
70
|
+
changed_tables: changed_tables
|
|
71
|
+
),
|
|
72
|
+
warnings: warnings.map { |w| serialize_warning(w) },
|
|
73
|
+
risk_score: risk[:score],
|
|
74
|
+
risk_level: risk[:level],
|
|
75
|
+
risk_factors: risk[:factors]
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def serialize_warning(warning)
|
|
80
|
+
{
|
|
81
|
+
rule: warning.rule,
|
|
82
|
+
severity: warning.severity,
|
|
83
|
+
table: warning.table,
|
|
84
|
+
column: warning.column,
|
|
85
|
+
message: warning.message
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def compute_risk(migration, all_migrations)
|
|
90
|
+
result = Services::SnapshotBuilder.call(migrations: all_migrations, up_to_version: migration[:version])
|
|
91
|
+
snapshot = snapshot_model_from(result[:schema_after], migration[:version])
|
|
92
|
+
warnings = Services::ScopedMigrationWarnings.call(snapshot: snapshot, migration: migration, diff: result[:diff])
|
|
93
|
+
Services::RiskScorer.new.call(warnings)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
class ApplicationController < Migflow.configuration.parent_controller.constantize
|
|
5
|
+
layout false
|
|
6
|
+
protect_from_forgery with: :null_session
|
|
7
|
+
before_action :run_user_before_action
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
render "migflow/application/index"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def run_user_before_action
|
|
16
|
+
hook = Migflow.configuration.authentication_hook
|
|
17
|
+
instance_exec(&hook) if hook
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def request_authentication
|
|
21
|
+
redir = Migflow.configuration.unauthenticated_redirect
|
|
22
|
+
if redir
|
|
23
|
+
session[:return_to_after_authenticating] = request.url
|
|
24
|
+
redirect_to instance_exec(&redir)
|
|
25
|
+
else
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def migrations_path
|
|
31
|
+
Migflow.configuration.resolved_migrations_path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def schema_path
|
|
35
|
+
Migflow.configuration.resolved_schema_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_json(status: :ok, **data)
|
|
39
|
+
render json: data, status: status
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_error(message, status: :unprocessable_entity)
|
|
43
|
+
render json: { error: message }, status: status
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def serialize_schema_patches(from_tables:, to_tables:, changed_tables: nil)
|
|
47
|
+
{
|
|
48
|
+
schema_patch: Services::SchemaPatchBuilder.call(
|
|
49
|
+
from_tables: from_tables,
|
|
50
|
+
to_tables: to_tables,
|
|
51
|
+
changed_tables: changed_tables,
|
|
52
|
+
include_unchanged: false
|
|
53
|
+
),
|
|
54
|
+
schema_patch_full: Services::SchemaPatchBuilder.call(
|
|
55
|
+
from_tables: from_tables,
|
|
56
|
+
to_tables: to_tables,
|
|
57
|
+
include_unchanged: true
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Migflow</title>
|
|
7
|
+
<%= stylesheet_link_tag "migflow/app" %>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div
|
|
11
|
+
id="schema-trail-root"
|
|
12
|
+
data-api-base="<%= migflow.root_path %>api"
|
|
13
|
+
></div>
|
|
14
|
+
<%= javascript_include_tag "migflow/app", defer: true %>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Migflow is a mountable Rails engine. The backend parses migration files and `schema.rb` on each request — no database tables, no background jobs. The frontend is a React SPA served as static assets from the engine's asset pipeline.
|
|
4
|
+
|
|
5
|
+
## High-level structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Host Rails app
|
|
9
|
+
└── Migflow::Engine (mounted at /migflow)
|
|
10
|
+
├── app/controllers/migflow/api/ # JSON REST API
|
|
11
|
+
├── app/assets/migflow/ # Compiled frontend (React SPA)
|
|
12
|
+
└── lib/migflow/
|
|
13
|
+
├── parsers/ # File I/O and extraction
|
|
14
|
+
├── models/ # Immutable value objects (Data.define)
|
|
15
|
+
├── services/ # Business logic
|
|
16
|
+
├── analyzers/rules/ # Audit rules
|
|
17
|
+
└── reporters/ # CI report output formats
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Data flow
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
db/migrate/*.rb ──► MigrationParser ──► Array<MigrationSnapshot>
|
|
24
|
+
db/schema.rb ──► SchemaParser ──► Hash<table, columns+indexes>
|
|
25
|
+
|
|
26
|
+
MigrationSnapshot
|
|
27
|
+
└──► MigrationDslScanner ──► SnapshotBuilder
|
|
28
|
+
(replays DSL calls to reconstruct
|
|
29
|
+
schema state at each point in history)
|
|
30
|
+
|
|
31
|
+
before_snapshot + after_snapshot
|
|
32
|
+
├──► DiffBuilder ──► SchemaDiff (added/removed tables, columns, indexes)
|
|
33
|
+
├──► SchemaPatchBuilder ──► unified diff hunks (schema.rb format)
|
|
34
|
+
├──► AuditAnalyzer ──► Array<Warning>
|
|
35
|
+
├──► RiskScorer ──► score (0–100) + level
|
|
36
|
+
└──► MigrationSummaryBuilder ──► human-readable one-liner
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Layers
|
|
40
|
+
|
|
41
|
+
### Parsers (`lib/migflow/parsers/`)
|
|
42
|
+
|
|
43
|
+
Read files on disk and return structured data. No business logic.
|
|
44
|
+
|
|
45
|
+
| Class | Input | Output |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `MigrationParser` | `db/migrate/*.rb` | `Array<MigrationSnapshot>` |
|
|
48
|
+
| `SchemaParser` | `db/schema.rb` | `Hash` of tables → columns + indexes |
|
|
49
|
+
|
|
50
|
+
### Models (`lib/migflow/models/`)
|
|
51
|
+
|
|
52
|
+
Immutable value objects using `Data.define` (Ruby 3.2+). No methods beyond accessors. Never subclass or add callbacks.
|
|
53
|
+
|
|
54
|
+
Key types: `MigrationSnapshot`, `SchemaDiff`, `Warning`, `MigrationDetail`.
|
|
55
|
+
|
|
56
|
+
### Services (`lib/migflow/services/`)
|
|
57
|
+
|
|
58
|
+
One class, one concern. Each takes plain Ruby values in and returns plain Ruby values out.
|
|
59
|
+
|
|
60
|
+
| Class | Responsibility |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `SnapshotBuilder` | Replays DSL calls via `MigrationDslScanner` to reconstruct schema at any historical point. The most complex class in the codebase — read it first before touching snapshot or diff logic. |
|
|
63
|
+
| `DiffBuilder` | Compares two schema snapshots and produces a `SchemaDiff` |
|
|
64
|
+
| `SchemaPatchBuilder` | Generates unified diff hunks in `schema.rb` format |
|
|
65
|
+
| `MigrationSummaryBuilder` | Produces a one-line human summary from a `SchemaDiff` |
|
|
66
|
+
| `ReportGenerator` | Orchestrates the full pipeline for CI report output |
|
|
67
|
+
|
|
68
|
+
### Analyzers (`lib/migflow/analyzers/rules/`)
|
|
69
|
+
|
|
70
|
+
Six rule classes, each implementing `#check(migration_content, tables:) → Array<Warning>`.
|
|
71
|
+
|
|
72
|
+
| Rule | What it catches |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `MissingIndexRule` | Foreign-key columns without an index |
|
|
75
|
+
| `MissingForeignKeyRule` | `_id` columns without a DB-level foreign key constraint |
|
|
76
|
+
| `StringWithoutLimitRule` | String columns declared without `:limit` |
|
|
77
|
+
| `MissingTimestampsRule` | Tables missing `created_at` / `updated_at` |
|
|
78
|
+
| `DangerousMigrationRule` | Destructive operations: `remove_column`, `drop_table`, `rename_column` |
|
|
79
|
+
| `NullColumnWithoutDefaultRule` | `null: false` column added without a `:default` |
|
|
80
|
+
|
|
81
|
+
Adding a new rule: create a class in `lib/migflow/analyzers/rules/`, inherit from `BaseRule`, implement `#check`, and register it in `AuditAnalyzer`.
|
|
82
|
+
|
|
83
|
+
### Controllers (`app/controllers/migflow/api/`)
|
|
84
|
+
|
|
85
|
+
Thin. Deserialize params, delegate to services, serialize JSON. No business logic lives here.
|
|
86
|
+
|
|
87
|
+
| Endpoint | Controller |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `GET /api/migrations` | `MigrationsController#index` |
|
|
90
|
+
| `GET /api/migrations/:version` | `MigrationsController#show` |
|
|
91
|
+
| `GET /api/diff` | `DiffController#show` |
|
|
92
|
+
|
|
93
|
+
### Reporters (`lib/migflow/reporters/`)
|
|
94
|
+
|
|
95
|
+
Consumed by the `migflow:report` Rake task. Each reporter receives a `ReportGenerator` result and renders it to a string.
|
|
96
|
+
|
|
97
|
+
| Class | Format |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `MarkdownReporter` | Human-readable table for terminal / GitHub Step Summary |
|
|
100
|
+
| `JsonReporter` | Machine-readable JSON for downstream tooling |
|
|
101
|
+
|
|
102
|
+
## Frontend
|
|
103
|
+
|
|
104
|
+
Built with React + TypeScript, bundled by Vite into `app/assets/migflow/`.
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
frontend/src/
|
|
108
|
+
├── api/client.ts # fetch wrappers for the three REST endpoints
|
|
109
|
+
├── store/useSchemaStore.ts # Zustand store — selected version, compare target, view mode
|
|
110
|
+
├── hooks/
|
|
111
|
+
│ └── useSchemaComparisonData.ts # central data hook — all React Query subscriptions
|
|
112
|
+
├── components/
|
|
113
|
+
│ ├── Timeline.tsx # left-side migration list
|
|
114
|
+
│ ├── CompareBar.tsx # base/target selectors and view mode switch
|
|
115
|
+
│ ├── SchemaCanvas.tsx # ERD canvas (ReactFlow / @xyflow/react)
|
|
116
|
+
│ ├── SchemaDiffView.tsx # unified diff panel (react-diff-view)
|
|
117
|
+
│ └── DetailPanel.tsx # right-side code + warnings panel
|
|
118
|
+
└── types/migration.ts # domain types shared across components
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**State management:** Zustand for UI state (selected version, compare target, view mode, collapsed tables). React Query (`@tanstack/react-query`) for server state with a 30-second stale time.
|
|
122
|
+
|
|
123
|
+
**`useSchemaComparisonData`** is the single hook that all canvas/diff components consume. It derives `comparePairValid`, fetches the three or four queries needed, and returns ready-to-use data plus loading flags. Avoid duplicating query logic in individual components — extend this hook instead.
|
|
124
|
+
|
|
125
|
+
## Key design decisions
|
|
126
|
+
|
|
127
|
+
- **No database.** Everything is derived from files on disk. This means zero setup beyond mounting the engine and zero schema migrations to run in the host app.
|
|
128
|
+
- **Immutable models.** `Data.define` value objects keep services easy to test in isolation — pass data in, get data out, no shared mutable state.
|
|
129
|
+
- **`SnapshotBuilder` replays history.** Rather than storing pre-computed snapshots, the engine replays migration DSL calls in order using `MigrationDslScanner`. This trades CPU for simplicity — no persistence layer, no cache invalidation problem.
|
|
130
|
+
- **Asset pipeline over CDN.** The frontend is served as compiled static files by the engine's asset pipeline. No CDN dependency, works in air-gapped environments.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rules/base_rule"
|
|
4
|
+
require_relative "rules/missing_index_rule"
|
|
5
|
+
require_relative "rules/missing_foreign_key_rule"
|
|
6
|
+
require_relative "rules/string_without_limit_rule"
|
|
7
|
+
require_relative "rules/missing_timestamps_rule"
|
|
8
|
+
require_relative "rules/dangerous_migration_rule"
|
|
9
|
+
require_relative "rules/null_column_without_default_rule"
|
|
10
|
+
|
|
11
|
+
module Migflow
|
|
12
|
+
module Analyzers
|
|
13
|
+
class AuditAnalyzer
|
|
14
|
+
RULES = [
|
|
15
|
+
Rules::MissingIndexRule,
|
|
16
|
+
Rules::MissingForeignKeyRule,
|
|
17
|
+
Rules::StringWithoutLimitRule,
|
|
18
|
+
Rules::MissingTimestampsRule,
|
|
19
|
+
Rules::DangerousMigrationRule,
|
|
20
|
+
Rules::NullColumnWithoutDefaultRule
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.call(snapshot:, raw_migrations: [])
|
|
24
|
+
new(snapshot: snapshot, raw_migrations: raw_migrations).analyze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(snapshot:, raw_migrations:)
|
|
28
|
+
@snapshot = snapshot
|
|
29
|
+
@raw_migrations = raw_migrations
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def analyze
|
|
33
|
+
schema_warnings + migration_warnings
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def schema_warnings
|
|
39
|
+
RULES.flat_map do |rule_class|
|
|
40
|
+
rule_class.new.call(@snapshot.tables)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Rails.logger.error "[Migflow] Rule #{rule_class} failed: #{e.message}"
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def migration_warnings
|
|
48
|
+
[
|
|
49
|
+
Rules::DangerousMigrationRule.new.call_with_migrations(@raw_migrations),
|
|
50
|
+
Rules::NullColumnWithoutDefaultRule.new.call_with_migrations(@raw_migrations)
|
|
51
|
+
].flatten
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Rails.logger.error "[Migflow] Migration analysis failed: #{e.message}"
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Rules
|
|
6
|
+
class BaseRule
|
|
7
|
+
def call(tables)
|
|
8
|
+
raise NotImplementedError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def warning(table:, message:, column: nil, severity: :warning)
|
|
14
|
+
Models::Warning.new(
|
|
15
|
+
rule: rule_name,
|
|
16
|
+
severity: severity,
|
|
17
|
+
table: table,
|
|
18
|
+
column: column,
|
|
19
|
+
message: message
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rule_name
|
|
24
|
+
short = self.class.name.split("::").last
|
|
25
|
+
short.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
26
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
27
|
+
.downcase
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Rules
|
|
6
|
+
class DangerousMigrationRule < BaseRule
|
|
7
|
+
DANGEROUS_OPERATIONS = {
|
|
8
|
+
remove_column: "Removes a column — may break running app instances",
|
|
9
|
+
drop_table: "Drops a table — destructive and irreversible",
|
|
10
|
+
rename_column: "Renames a column — breaks existing queries and code"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def call(_tables)
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call_with_migrations(raw_migrations)
|
|
18
|
+
raw_migrations.flat_map do |migration|
|
|
19
|
+
detect_dangers(migration[:raw_content], migration[:filename])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def detect_dangers(content, filename)
|
|
26
|
+
DANGEROUS_OPERATIONS.filter_map do |operation, message|
|
|
27
|
+
next unless content.match?(/\b#{operation}\b/)
|
|
28
|
+
|
|
29
|
+
warning(
|
|
30
|
+
table: extract_table(content, operation),
|
|
31
|
+
message: "#{filename}: #{message}",
|
|
32
|
+
severity: :error
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_table(content, operation)
|
|
38
|
+
match = content.match(/#{operation}\s*[:(]\s*[:"']?(\w+)/)
|
|
39
|
+
match ? match[1] : "unknown"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Rules
|
|
6
|
+
class MissingForeignKeyRule < BaseRule
|
|
7
|
+
FOREIGN_KEY_PATTERN = /foreign_key.*references/
|
|
8
|
+
|
|
9
|
+
def call(tables)
|
|
10
|
+
tables.flat_map do |table_name, table|
|
|
11
|
+
foreign_key_columns(table).map do |col|
|
|
12
|
+
warning(
|
|
13
|
+
table: table_name,
|
|
14
|
+
column: col[:name],
|
|
15
|
+
message: "Column '#{col[:name]}' looks like a foreign key but has no foreign key constraint",
|
|
16
|
+
severity: :info
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def foreign_key_columns(table)
|
|
25
|
+
table[:columns].select { |col| col[:name].end_with?("_id") }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Rules
|
|
6
|
+
class MissingIndexRule < BaseRule
|
|
7
|
+
def call(tables)
|
|
8
|
+
tables.flat_map do |table_name, table|
|
|
9
|
+
foreign_key_columns(table).reject { |col| indexed?(col, table) }.map do |col|
|
|
10
|
+
warning(
|
|
11
|
+
table: table_name,
|
|
12
|
+
column: col[:name],
|
|
13
|
+
message: "Column '#{col[:name]}' looks like a foreign key but has no index",
|
|
14
|
+
severity: :warning
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def foreign_key_columns(table)
|
|
23
|
+
table[:columns].select { |col| col[:name].end_with?("_id") }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def indexed?(column, table)
|
|
27
|
+
table[:indexes].any? { |idx| idx[:columns].include?(column[:name]) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Migflow
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Rules
|
|
6
|
+
class MissingTimestampsRule < BaseRule
|
|
7
|
+
TIMESTAMP_COLUMNS = %w[created_at updated_at].freeze
|
|
8
|
+
MAX_JOIN_TABLE_COLUMNS = 3
|
|
9
|
+
|
|
10
|
+
def call(tables)
|
|
11
|
+
tables.reject { |_, table| join_table?(table) }.filter_map do |table_name, table|
|
|
12
|
+
next if has_timestamps?(table)
|
|
13
|
+
|
|
14
|
+
warning(
|
|
15
|
+
table: table_name,
|
|
16
|
+
message: "Table '#{table_name}' is missing created_at and/or updated_at",
|
|
17
|
+
severity: :warning
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def has_timestamps?(table)
|
|
25
|
+
column_names = table[:columns].map { |c| c[:name] }
|
|
26
|
+
TIMESTAMP_COLUMNS.all? { |ts| column_names.include?(ts) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def join_table?(table)
|
|
30
|
+
columns = table[:columns]
|
|
31
|
+
return false if columns.size > MAX_JOIN_TABLE_COLUMNS
|
|
32
|
+
|
|
33
|
+
columns.all? { |col| col[:name].end_with?("_id") }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|