actual_db_schema 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4b87fc03cd39a068d303e4e3844a66eb4bd8ced5c7e934c804c0049a3ff141f
4
- data.tar.gz: ab30dd516f5c492b2de6e8a41f6d22adcbdc49623a2329ff1948a8114c754823
3
+ metadata.gz: d78699aee32920e20a6dd084512949d9cc88e7878531de087295d43833463193
4
+ data.tar.gz: 1e55c7f7b15e0a2fbace58cc80c32deb2061bbe6cd8d8da69f8f35682e9d5b75
5
5
  SHA512:
6
- metadata.gz: dfa64d7885732bc7e736dcff0c3a66ab5c30d05e80695a729ef8c48c9d478cee8ada3d3dd4566ff0824f884cfd0d244c367915d55971a79ee4a5b60fc53a9e67
7
- data.tar.gz: 72a54dd856b12990db02432c63d2aa992e77596ea3af0a4b6e3f83bf31be0a6069edd60222940773adf53f5a3fb286031fa512c7146bacf60fd3fc5424d56036
6
+ metadata.gz: df1251d4d45888438f9e5ab3585eafa2f2eec2ace597ec0b6985479dc835a67460990e5b6e67534bfeceb779f088b64c65fcff0c9a7b7e16007a90c365f7d93e
7
+ data.tar.gz: 94a00404bb421f3b3a1124c456358003da71762a652cca2d6e89763bdbc872ddf3f86c2c68ca9b5cc26e12a514869f9c11aba972dfb705f816dc3d43200e63a1
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.8.3] - 2025-03-03
2
+
3
+ - View Schema with Migration Annotations in the UI
4
+ - Clean Up Broken Migrations
5
+ - Filter Migrations in the UI
6
+ - Customize Your Migrated Folder Location
7
+
1
8
  ## [0.8.2] - 2025-02-06
2
9
 
3
10
  - Show migration name in the schema.rb diff that caused the change
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- actual_db_schema (0.8.2)
4
+ actual_db_schema (0.8.3)
5
5
  activerecord
6
6
  activesupport
7
7
  ast
data/README.md CHANGED
@@ -72,6 +72,25 @@ The gem offers the following rake tasks that can be manually run according to yo
72
72
  - `rails db:rollback_branches:manual` - run it to manually rolls back phantom migrations one by one.
73
73
  - `rails db:phantom_migrations` - displays a list of phantom migrations.
74
74
 
75
+ ## Migrated Folder Configuration
76
+
77
+ By default, `actual_db_schema` stores all run migrations in the `tmp/migrated` folder. However, if you want to change this location, you can configure it in two ways:
78
+
79
+ ### 1. Using Environment Variable
80
+
81
+ Set the environment variable `ACTUAL_DB_SCHEMA_MIGRATED_FOLDER` to your desired folder path:
82
+
83
+ ```sh
84
+ export ACTUAL_DB_SCHEMA_MIGRATED_FOLDER="custom/migrated"
85
+ ```
86
+
87
+ ### 2. Using Initializer
88
+ Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`):
89
+
90
+ ```ruby
91
+ config.migrated_folder = Rails.root.join("custom", "migrated")
92
+ ```
93
+
75
94
  ## Accessing the UI
76
95
 
77
96
  The UI for managing migrations is enabled automatically. To access the UI, simply navigate to the following URL in your web browser:
@@ -228,6 +247,49 @@ remove_index :users, :email
228
247
  rename_column :users, :username, :handle
229
248
  ```
230
249
 
250
+ ## Delete Broken Migrations
251
+
252
+ A migration is considered broken if it has been migrated in the database but the corresponding migration file is missing. This functionality allows you to safely delete these broken versions from the database to keep it clean.
253
+
254
+ You can delete broken migrations using either of the following methods:
255
+
256
+ ### 1. Using the UI
257
+
258
+ Navigate to the following URL in your web browser:
259
+ ```
260
+ http://localhost:3000/rails/broken_versions
261
+ ```
262
+
263
+ This page lists all broken versions and provides an option to delete them.
264
+
265
+ ### 2. Using a Rake Task
266
+
267
+ To delete all broken migrations, run:
268
+ ```sh
269
+ rake actual_db_schema:delete_broken_versions
270
+ ```
271
+
272
+ To delete specific migrations, pass the migration version(s) and optionally a database:
273
+ ```sh
274
+ rake actual_db_schema:delete_broken_versions[<version>, <version>]
275
+ ```
276
+
277
+ - `<version>` – The migration version(s) to delete (space-separated if multiple).
278
+ - `<database>` (optional) – Specify a database if using multiple databases.
279
+
280
+ #### Examples:
281
+
282
+ ```sh
283
+ # Delete all broken migrations
284
+ rake actual_db_schema:delete_broken_versions
285
+
286
+ # Delete specific migrations
287
+ rake actual_db_schema:delete_broken_versions["20250224103352 20250224103358"]
288
+
289
+ # Delete specific migrations from a specific database
290
+ rake actual_db_schema:delete_broken_versions["20250224103352 20250224103358", "primary"]
291
+ ```
292
+
231
293
  ## Development
232
294
 
233
295
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -241,7 +303,8 @@ To release a new version do the following in the order:
241
303
  - `bundle install` to update `Gemfile.lock`;
242
304
  - make the commit and push;
243
305
  - run `bundle exec rake release`. This will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org);
244
- - [announce the new release on GitHub](https://github.com/widefix/actual_db_schema/releases).
306
+ - [announce the new release on GitHub](https://github.com/widefix/actual_db_schema/releases);
307
+ - close the milestone on GitHub.
245
308
 
246
309
  ### Running Tests with Specific Rails Versions
247
310
 
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActualDbSchema
4
+ # Controller for managing broken migration versions.
5
+ class BrokenVersionsController < ActionController::Base
6
+ protect_from_forgery with: :exception
7
+ skip_before_action :verify_authenticity_token
8
+
9
+ def index; end
10
+
11
+ def delete
12
+ handle_delete(params[:id], params[:database])
13
+ redirect_to broken_versions_path
14
+ end
15
+
16
+ def delete_all
17
+ handle_delete_all
18
+ redirect_to broken_versions_path
19
+ end
20
+
21
+ private
22
+
23
+ def handle_delete(id, database)
24
+ ActualDbSchema::Migration.instance.delete(id, database)
25
+ flash[:notice] = "Migration #{id} was successfully deleted."
26
+ rescue StandardError => e
27
+ flash[:alert] = e.message
28
+ end
29
+
30
+ def handle_delete_all
31
+ ActualDbSchema::Migration.instance.delete_all
32
+ flash[:notice] = "All broken versions were successfully deleted."
33
+ rescue StandardError => e
34
+ flash[:alert] = e.message
35
+ end
36
+
37
+ helper_method def broken_versions
38
+ @broken_versions ||= ActualDbSchema::Migration.instance.broken_versions
39
+ end
40
+ end
41
+ end
@@ -40,6 +40,20 @@ module ActualDbSchema
40
40
 
41
41
  helper_method def migrations
42
42
  @migrations ||= ActualDbSchema::Migration.instance.all
43
+ query = params[:query].to_s.strip.downcase
44
+
45
+ return @migrations if query.blank?
46
+
47
+ @migrations.select do |migration|
48
+ file_name_matches = migration[:filename].include?(query)
49
+ content_matches = begin
50
+ File.read(migration[:filename]).downcase.include?(query)
51
+ rescue StandardError
52
+ false
53
+ end
54
+
55
+ file_name_matches || content_matches
56
+ end
43
57
  end
44
58
 
45
59
  helper_method def migration
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActualDbSchema
4
+ # Controller to display the database schema diff.
5
+ class SchemaController < ActionController::Base
6
+ protect_from_forgery with: :exception
7
+ skip_before_action :verify_authenticity_token
8
+
9
+ def index; end
10
+
11
+ private
12
+
13
+ helper_method def schema_diff_html
14
+ schema_diff = ActualDbSchema::SchemaDiffHtml.new("./db/schema.rb", "db/migrate")
15
+ schema_diff.render_html(params[:table])
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,64 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Broken Versions</title>
5
+ <%= render partial: 'actual_db_schema/shared/js' %>
6
+ <%= render partial: 'actual_db_schema/shared/style' %>
7
+ </head>
8
+ <body>
9
+ <div>
10
+ <% flash.each do |key, message| %>
11
+ <div class="flash <%= key %>"><%= message %></div>
12
+ <% end %>
13
+ <h2>Broken Versions</h2>
14
+ <p>
15
+ These are versions that were migrated in the database, but the corresponding migration file is missing.
16
+ You can safely delete them from the database to clean up.
17
+ </p>
18
+ <div class="top-buttons">
19
+ <%= link_to 'All Migrations', migrations_path, class: "top-button" %>
20
+ <% if broken_versions.present? %>
21
+ <%= button_to '✖ Delete all',
22
+ delete_all_broken_versions_path,
23
+ method: :post,
24
+ data: { confirm: 'These migrations do not have corresponding migration files. Proceeding will remove these entries from the `schema_migrations` table. Are you sure you want to continue?' },
25
+ class: 'button migration-action' %>
26
+ <% end %>
27
+ </div>
28
+ <% if broken_versions.present? %>
29
+ <table>
30
+ <thead>
31
+ <tr>
32
+ <th>Status</th>
33
+ <th>Migration ID</th>
34
+ <th>Branch</th>
35
+ <th>Database</th>
36
+ <th>Actions</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <% broken_versions.each do |version| %>
41
+ <tr class="migration-row phantom">
42
+ <td><%= version[:status] %></td>
43
+ <td><%= version[:version] %></td>
44
+ <td><%= version[:branch] %></td>
45
+ <td><%= version[:database] %></td>
46
+ <td>
47
+ <div class='button-container'>
48
+ <%= button_to '✖ Delete',
49
+ delete_broken_version_path(id: version[:version], database: version[:database]),
50
+ method: :post,
51
+ data: { confirm: 'This migration does not have a corresponding migration file. Proceeding will remove its entry from the `schema_migrations` table. Are you sure you want to continue?' },
52
+ class: 'button migration-action' %>
53
+ </div>
54
+ </td>
55
+ </tr>
56
+ <% end %>
57
+ </tbody>
58
+ </table>
59
+ <% else %>
60
+ <p>No broken versions found.</p>
61
+ <% end %>
62
+ </div>
63
+ </body>
64
+ </html>
@@ -14,57 +14,69 @@
14
14
  <p>
15
15
  <span style="background-color: #ffe6e6; padding: 0 5px;">Red rows</span> represent phantom migrations.
16
16
  </p>
17
- <div class="top-buttons">
18
- <%= link_to 'Phantom Migrations', phantom_migrations_path, class: "top-button" %>
19
- </div>
20
- <% if migrations.present? %>
21
- <table>
22
- <thead>
23
- <tr>
24
- <th>Status</th>
25
- <th>Migration ID</th>
26
- <th>Name</th>
27
- <th>Branch</th>
28
- <th>Database</th>
29
- <th>Actions</th>
30
- </tr>
31
- </thead>
32
- <tbody>
33
- <% migrations.each do |migration| %>
34
- <tr class="migration-row <%= migration[:phantom] ? 'phantom' : 'normal' %>">
35
- <td><%= migration[:status] %></td>
36
- <td><%= migration[:version] %></td>
37
- <td>
38
- <div class="truncate-text" title="<%= migration[:name] %>">
39
- <%= migration[:name] %>
40
- </div>
41
- </td>
42
- <td><%= migration[:branch] %></td>
43
- <td><%= migration[:database] %></td>
44
- <td>
45
- <div class='button-container'>
46
- <%= link_to '👁 Show',
47
- migration_path(id: migration[:version], database: migration[:database]),
48
- class: 'button' %>
49
- <%= button_to '⎌ Rollback',
50
- rollback_migration_path(id: migration[:version], database: migration[:database]),
51
- method: :post,
52
- class: 'button migration-action',
53
- style: ('display: none;' if migration[:status] == "down") %>
54
- <%= button_to '⬆ Migrate',
55
- migrate_migration_path(id: migration[:version], database: migration[:database]),
56
- method: :post,
57
- class: 'button migration-action',
58
- style: ('display: none;' if migration[:status] == "up" || migration[:phantom]) %>
59
- </div>
60
- </td>
61
- </tr>
17
+ <div class="container">
18
+ <div class="top-controls">
19
+ <div class="top-buttons">
20
+ <%= link_to 'Phantom Migrations', phantom_migrations_path, class: "top-button" %>
21
+ <%= link_to 'Broken Versions', broken_versions_path, class: "top-button" %>
22
+ <%= link_to 'View Schema', schema_path, class: "top-button" %>
23
+ </div>
24
+ <div class="top-search">
25
+ <%= form_tag migrations_path, method: :get, class: "search-form" do %>
26
+ <span class="search-icon">🔍</span>
27
+ <%= text_field_tag :query, params[:query], placeholder: "Search migrations by name or content", class: "search-input" %>
62
28
  <% end %>
63
- </tbody>
64
- </table>
65
- <% else %>
66
- <p>No migrations found.</p>
67
- <% end %>
29
+ </div>
30
+ </div>
31
+ <% if migrations.present? %>
32
+ <table>
33
+ <thead>
34
+ <tr>
35
+ <th>Status</th>
36
+ <th>Migration ID</th>
37
+ <th>Name</th>
38
+ <th>Branch</th>
39
+ <th>Database</th>
40
+ <th>Actions</th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ <% migrations.each do |migration| %>
45
+ <tr class="migration-row <%= migration[:phantom] ? 'phantom' : 'normal' %>">
46
+ <td><%= migration[:status] %></td>
47
+ <td><%= migration[:version] %></td>
48
+ <td>
49
+ <div class="truncate-text" title="<%= migration[:name] %>">
50
+ <%= migration[:name] %>
51
+ </div>
52
+ </td>
53
+ <td><%= migration[:branch] %></td>
54
+ <td><%= migration[:database] %></td>
55
+ <td>
56
+ <div class='button-container'>
57
+ <%= link_to '👁 Show',
58
+ migration_path(id: migration[:version], database: migration[:database]),
59
+ class: 'button' %>
60
+ <%= button_to '⎌ Rollback',
61
+ rollback_migration_path(id: migration[:version], database: migration[:database]),
62
+ method: :post,
63
+ class: 'button migration-action',
64
+ style: ('display: none;' if migration[:status] == "down") %>
65
+ <%= button_to '⬆ Migrate',
66
+ migrate_migration_path(id: migration[:version], database: migration[:database]),
67
+ method: :post,
68
+ class: 'button migration-action',
69
+ style: ('display: none;' if migration[:status] == "up" || migration[:phantom]) %>
70
+ </div>
71
+ </td>
72
+ </tr>
73
+ <% end %>
74
+ </tbody>
75
+ </table>
76
+ <% else %>
77
+ <p>No migrations found.</p>
78
+ <% end %>
79
+ </div>
68
80
  </div>
69
81
  </body>
70
82
  </html>
@@ -0,0 +1,31 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Database Schema</title>
5
+ <%= render partial: 'actual_db_schema/shared/js' %>
6
+ <%= render partial: 'actual_db_schema/shared/style' %>
7
+ </head>
8
+ <body>
9
+ <div>
10
+ <% flash.each do |key, message| %>
11
+ <div class="flash <%= key %>"><%= message %></div>
12
+ <% end %>
13
+ <h2>Database Schema</h2>
14
+ <div class="top-controls">
15
+ <div class="top-buttons">
16
+ <%= link_to 'All Migrations', migrations_path, class: "top-button" %>
17
+ </div>
18
+ <div class="top-search">
19
+ <%= form_tag schema_path, method: :get, class: "search-form" do %>
20
+ <span class="search-icon">🔍</span>
21
+ <%= text_field_tag :table, params[:table], placeholder: "Filter by table name", class: "search-input" %>
22
+ <% end %>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="schema-diff">
27
+ <pre><%= raw schema_diff_html %></pre>
28
+ </div>
29
+ </div>
30
+ </body>
31
+ </html>
@@ -4,6 +4,12 @@
4
4
 
5
5
  migrationActions.forEach(button => {
6
6
  button.addEventListener('click', function(event) {
7
+ const confirmMessage = button.dataset.confirm;
8
+ if (confirmMessage && !confirm(confirmMessage)) {
9
+ event.preventDefault();
10
+ return;
11
+ }
12
+
7
13
  const originalText = button.value;
8
14
  button.value = 'Loading...';
9
15
  disableButtons();
@@ -15,6 +15,10 @@
15
15
  padding-left: 10px;
16
16
  }
17
17
 
18
+ p {
19
+ padding-left: 10px;
20
+ }
21
+
18
22
  table {
19
23
  margin: 0;
20
24
  border-collapse: collapse;
@@ -65,6 +69,7 @@
65
69
  text-decoration: none;
66
70
  display: inline-block;
67
71
  margin: 0 2px;
72
+ margin-right: 8px;
68
73
  cursor: pointer;
69
74
  border-radius: 4px;
70
75
  transition: background-color 0.3s;
@@ -113,4 +118,44 @@
113
118
  background-color: #f8d7da;
114
119
  color: #721c24;
115
120
  }
121
+
122
+ .container {
123
+ display: inline-block;
124
+ max-width: 100%;
125
+ }
126
+
127
+ .top-controls {
128
+ display: flex;
129
+ justify-content: space-between;
130
+ align-items: center;
131
+ width: 100%;
132
+ }
133
+
134
+ .top-search {
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: flex-end;
138
+ }
139
+
140
+ .search-form {
141
+ display: flex;
142
+ align-items: center;
143
+ }
144
+
145
+ .search-form .search-icon {
146
+ margin-right: 5px;
147
+ font-size: 16px;
148
+ }
149
+
150
+ .search-form .search-input {
151
+ padding: 5px;
152
+ border: 1px solid #ccc;
153
+ border-radius: 4px;
154
+ font-size: 13px;
155
+ width: 250px;
156
+ }
157
+
158
+ .schema-diff {
159
+ margin-left: 8px;
160
+ }
116
161
  </style>
data/config/routes.rb CHANGED
@@ -15,4 +15,14 @@ ActualDbSchema::Engine.routes.draw do
15
15
  post :rollback_all
16
16
  end
17
17
  end
18
+ resources :broken_versions, only: %i[index] do
19
+ member do
20
+ post :delete
21
+ end
22
+ collection do
23
+ post :delete_all
24
+ end
25
+ end
26
+
27
+ get "schema", to: "schema#index", as: :schema
18
28
  end
@@ -4,7 +4,7 @@ module ActualDbSchema
4
4
  # Manages the configuration settings for the gem.
5
5
  class Configuration
6
6
  attr_accessor :enabled, :auto_rollback_disabled, :ui_enabled, :git_hooks_enabled, :multi_tenant_schemas,
7
- :console_migrations_enabled
7
+ :console_migrations_enabled, :migrated_folder
8
8
 
9
9
  def initialize
10
10
  @enabled = Rails.env.development?
@@ -13,6 +13,7 @@ module ActualDbSchema
13
13
  @git_hooks_enabled = ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?
14
14
  @multi_tenant_schemas = nil
15
15
  @console_migrations_enabled = ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?
16
+ @migrated_folder = ENV["ACTUAL_DB_SCHEMA_MIGRATED_FOLDER"].present?
16
17
  end
17
18
 
18
19
  def [](key)
@@ -73,6 +73,43 @@ module ActualDbSchema
73
73
  end
74
74
  end
75
75
 
76
+ def broken_versions
77
+ broken = []
78
+ MigrationContext.instance.each do |context|
79
+ context.migrations_status.each do |status, version, name|
80
+ next unless name == "********** NO FILE **********"
81
+
82
+ broken << Migration.new(
83
+ status: status,
84
+ version: version.to_s,
85
+ name: name,
86
+ branch: branch_for(version),
87
+ database: ActualDbSchema.db_config[:database]
88
+ )
89
+ end
90
+ end
91
+
92
+ broken
93
+ end
94
+
95
+ def delete(version, database)
96
+ validate_broken_migration(version, database)
97
+
98
+ MigrationContext.instance.each do
99
+ next if database && ActualDbSchema.db_config[:database] != database
100
+ next if ActiveRecord::Base.connection.select_values("SELECT version FROM schema_migrations").exclude?(version)
101
+
102
+ ActiveRecord::Base.connection.execute("DELETE FROM schema_migrations WHERE version = '#{version}'")
103
+ break
104
+ end
105
+ end
106
+
107
+ def delete_all
108
+ broken_versions.each do |version|
109
+ delete(version.version, version.database)
110
+ end
111
+ end
112
+
76
113
  private
77
114
 
78
115
  def build_migration_struct(status, migration)
@@ -92,7 +129,8 @@ module ActualDbSchema
92
129
  end
93
130
 
94
131
  def phantom?(migration)
95
- migration.filename.include?("/tmp/migrated")
132
+ migrated_folder = ActualDbSchema.config[:migrated_folder].presence || "/tmp/migrated"
133
+ migration.filename.include?(migrated_folder.to_s)
96
134
  end
97
135
 
98
136
  def should_include?(status, migration)
@@ -115,5 +153,15 @@ module ActualDbSchema
115
153
  @metadata ||= {}
116
154
  @metadata[ActualDbSchema.db_config[:database]] ||= ActualDbSchema::Store.instance.read
117
155
  end
156
+
157
+ def validate_broken_migration(version, database)
158
+ if database
159
+ unless broken_versions.any? { |v| v.version == version && v.database == database }
160
+ raise StandardError, "Migration is not broken for database #{database}."
161
+ end
162
+ else
163
+ raise StandardError, "Migration is not broken." unless broken_versions.any? { |v| v.version == version }
164
+ end
165
+ end
118
166
  end
119
167
  end
@@ -63,17 +63,25 @@ module ActualDbSchema
63
63
  end
64
64
 
65
65
  def migrated_folders
66
+ dirs = find_migrated_folders
67
+
68
+ if (configured_migrated_folder = ActualDbSchema.config[:migrated_folder].presence)
69
+ relative_migrated_folder = configured_migrated_folder.to_s.sub(%r{\A#{Regexp.escape(Rails.root.to_s)}/?}, "")
70
+ dirs << relative_migrated_folder unless dirs.include?(relative_migrated_folder)
71
+ end
72
+
73
+ dirs.map { |dir| dir.sub(%r{\A\./}, "") }.uniq
74
+ end
75
+
76
+ def find_migrated_folders
66
77
  path_parts = Pathname.new(@migrations_path).each_filename.to_a
67
78
  db_index = path_parts.index("db")
68
-
69
79
  return [] unless db_index
70
80
 
71
81
  base_path = db_index.zero? ? "." : File.join(*path_parts[0...db_index])
72
- dirs = Dir[File.join(base_path, "tmp", "migrated*")].select do |path|
82
+ Dir[File.join(base_path, "tmp", "migrated*")].select do |path|
73
83
  File.directory?(path) && File.basename(path).match?(/^migrated(_[a-zA-Z0-9_-]+)?$/)
74
84
  end
75
-
76
- dirs.map { |dir| dir.sub(%r{\A\./}, "") }
77
85
  end
78
86
 
79
87
  def generate_diff(old_content, new_content)
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActualDbSchema
4
+ # Generates an HTML representation of the schema diff,
5
+ # annotated with the migrations responsible for each change.
6
+ class SchemaDiffHtml < SchemaDiff
7
+ def render_html(table_filter)
8
+ return unless old_schema_content && !old_schema_content.strip.empty?
9
+
10
+ @full_diff_html ||= generate_diff_html
11
+ filter = table_filter.to_s.strip.downcase
12
+
13
+ filter.empty? ? @full_diff_html : extract_table_section(@full_diff_html, filter)
14
+ end
15
+
16
+ private
17
+
18
+ def generate_diff_html
19
+ diff_output = generate_full_diff(old_schema_content, new_schema_content)
20
+ return "<pre>#{ERB::Util.html_escape(new_schema_content)}</pre>" if diff_output.strip.empty?
21
+
22
+ process_diff_output_for_html(diff_output)
23
+ end
24
+
25
+ def generate_full_diff(old_content, new_content)
26
+ Tempfile.create("old_schema") do |old_file|
27
+ Tempfile.create("new_schema") do |new_file|
28
+ old_file.write(old_content)
29
+ new_file.write(new_content)
30
+ old_file.rewind
31
+ new_file.rewind
32
+
33
+ `diff -u -U 9999999 #{old_file.path} #{new_file.path}`
34
+ end
35
+ end
36
+ end
37
+
38
+ def process_diff_output_for_html(diff_str)
39
+ current_table = nil
40
+ result_lines = []
41
+ @tables = {}
42
+ table_start = nil
43
+ block_depth = 1
44
+
45
+ diff_str.lines.each do |line|
46
+ next if line.start_with?("---") || line.start_with?("+++") || line.match(/^@@/)
47
+
48
+ current_table, table_start, block_depth =
49
+ process_table(line, current_table, table_start, result_lines.size, block_depth)
50
+ result_lines << (%w[+ -].include?(line[0]) ? handle_diff_line_html(line, current_table) : line)
51
+ end
52
+
53
+ result_lines.join
54
+ end
55
+
56
+ def process_table(line, current_table, table_start, table_end, block_depth)
57
+ if (ct = line.match(/create_table\s+["']([^"']+)["']/))
58
+ return [ct[1], table_end, block_depth]
59
+ end
60
+
61
+ return [current_table, table_start, block_depth] unless current_table
62
+
63
+ block_depth += line.scan(/\bdo\b/).size unless line.match(/create_table\s+["']([^"']+)["']/)
64
+ block_depth -= line.scan(/\bend\b/).size
65
+
66
+ if block_depth.zero?
67
+ @tables[current_table] = { start: table_start, end: table_end }
68
+ current_table = nil
69
+ block_depth = 1
70
+ end
71
+
72
+ [current_table, table_start, block_depth]
73
+ end
74
+
75
+ def handle_diff_line_html(line, current_table)
76
+ sign = line[0]
77
+ line_content = line[1..]
78
+ color = SIGN_COLORS[sign]
79
+
80
+ action, name = detect_action_and_name(line_content, sign, current_table)
81
+ annotation = action ? find_migrations(action, current_table, name) : []
82
+ annotation.any? ? annotate_line(line, annotation, color) : colorize_html(line, color)
83
+ end
84
+
85
+ def annotate_line(line, migration_file_paths, color)
86
+ links_html = migration_file_paths.map { |path| link_to_migration(path) }.join(", ")
87
+ "#{colorize_html(line.chomp, color)}#{colorize_html(" // #{links_html} //", :gray)}\n"
88
+ end
89
+
90
+ def colorize_html(text, color)
91
+ safe = ERB::Util.html_escape(text)
92
+
93
+ case color
94
+ when :green
95
+ %(<span style="color: green">#{safe}</span>)
96
+ when :red
97
+ %(<span style="color: red">#{safe}</span>)
98
+ when :gray
99
+ %(<span style="color: gray">#{text}</span>)
100
+ end
101
+ end
102
+
103
+ def link_to_migration(migration_file_path)
104
+ migration = migrations.detect { |m| m.filename == migration_file_path }
105
+ return ERB::Util.html_escape(migration_file_path) unless migration
106
+
107
+ url = "migrations/#{migration.version}?database=#{migration.database}"
108
+ "<a href=\"#{url}\">#{ERB::Util.html_escape(migration_file_path)}</a>"
109
+ end
110
+
111
+ def migrations
112
+ @migrations ||= ActualDbSchema::Migration.instance.all
113
+ end
114
+
115
+ def extract_table_section(full_diff_html, table_name)
116
+ return unless @tables[table_name]
117
+
118
+ range = @tables[table_name]
119
+ full_diff_html.lines[range[:start]..range[:end]].join
120
+ end
121
+ end
122
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActualDbSchema
4
- VERSION = "0.8.2"
4
+ VERSION = "0.8.3"
5
5
  end
@@ -19,6 +19,7 @@ require_relative "actual_db_schema/git_hooks"
19
19
  require_relative "actual_db_schema/multi_tenant"
20
20
  require_relative "actual_db_schema/railtie"
21
21
  require_relative "actual_db_schema/schema_diff"
22
+ require_relative "actual_db_schema/schema_diff_html"
22
23
  require_relative "actual_db_schema/schema_parser"
23
24
 
24
25
  require_relative "actual_db_schema/commands/base"
@@ -58,7 +59,7 @@ module ActualDbSchema
58
59
  end
59
60
 
60
61
  def self.default_migrated_folder
61
- Rails.root.join("tmp", "migrated")
62
+ config[:migrated_folder] || Rails.root.join("tmp", "migrated")
62
63
  end
63
64
 
64
65
  def self.migrations_paths
@@ -21,7 +21,11 @@ ActualDbSchema.configure do |config|
21
21
  # If your application leverages multiple schemas for multi-tenancy, define the active schemas.
22
22
  # config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] }
23
23
 
24
- # Enable console migrations
24
+ # Enable console migrations.
25
25
  # config.console_migrations_enabled = true
26
26
  config.console_migrations_enabled = ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?
27
+
28
+ # Define the migrated folder location.
29
+ # config.migrated_folder = Rails.root.join("custom", "migrated")
30
+ config.migrated_folder = Rails.root.join("tmp", "migrated")
27
31
  end
@@ -55,11 +55,35 @@ namespace :actual_db_schema do # rubocop:disable Metrics/BlockLength
55
55
  end
56
56
 
57
57
  desc "Show the schema.rb diff annotated with the migrations that made the changes"
58
- task :diff_schema_with_migrations, [:schema_path, :migrations_path] do |_, args|
59
- schema_path = args[:schema_path] || "db/schema.rb"
58
+ task :diff_schema_with_migrations, %i[schema_path migrations_path] => :environment do |_, args|
59
+ schema_path = args[:schema_path] || "./db/schema.rb"
60
60
  migrations_path = args[:migrations_path] || "db/migrate"
61
61
 
62
62
  schema_diff = ActualDbSchema::SchemaDiff.new(schema_path, migrations_path)
63
63
  puts schema_diff.render
64
64
  end
65
+
66
+ desc "Delete broken migration versions from the database"
67
+ task :delete_broken_versions, %i[versions database] => :environment do |_, args|
68
+ extend ActualDbSchema::OutputFormatter
69
+
70
+ if args[:versions]
71
+ versions = args[:versions].split(" ").map(&:strip)
72
+ versions.each do |version|
73
+ ActualDbSchema::Migration.instance.delete(version, args[:database])
74
+ puts colorize("[ActualDbSchema] Migration #{version} was successfully deleted.", :green)
75
+ rescue StandardError => e
76
+ puts colorize("[ActualDbSchema] Error deleting version #{version}: #{e.message}", :red)
77
+ end
78
+ elsif ActualDbSchema::Migration.instance.broken_versions.empty?
79
+ puts colorize("[ActualDbSchema] No broken versions found.", :gray)
80
+ else
81
+ begin
82
+ ActualDbSchema::Migration.instance.delete_all
83
+ puts colorize("[ActualDbSchema] All broken versions were successfully deleted.", :green)
84
+ rescue StandardError => e
85
+ puts colorize("[ActualDbSchema] Error deleting all broken versions: #{e.message}", :red)
86
+ end
87
+ end
88
+ end
65
89
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actual_db_schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Kaleshka
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-02-06 00:00:00.000000000 Z
11
+ date: 2025-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -155,12 +155,16 @@ files:
155
155
  - README.md
156
156
  - Rakefile
157
157
  - actual_db_schema.gemspec
158
+ - app/controllers/actual_db_schema/broken_versions_controller.rb
158
159
  - app/controllers/actual_db_schema/migrations_controller.rb
159
160
  - app/controllers/actual_db_schema/phantom_migrations_controller.rb
161
+ - app/controllers/actual_db_schema/schema_controller.rb
162
+ - app/views/actual_db_schema/broken_versions/index.html.erb
160
163
  - app/views/actual_db_schema/migrations/index.html.erb
161
164
  - app/views/actual_db_schema/migrations/show.html.erb
162
165
  - app/views/actual_db_schema/phantom_migrations/index.html.erb
163
166
  - app/views/actual_db_schema/phantom_migrations/show.html.erb
167
+ - app/views/actual_db_schema/schema/index.html.erb
164
168
  - app/views/actual_db_schema/shared/_js.html
165
169
  - app/views/actual_db_schema/shared/_style.html
166
170
  - config/routes.rb
@@ -192,6 +196,7 @@ files:
192
196
  - lib/actual_db_schema/patches/migrator.rb
193
197
  - lib/actual_db_schema/railtie.rb
194
198
  - lib/actual_db_schema/schema_diff.rb
199
+ - lib/actual_db_schema/schema_diff_html.rb
195
200
  - lib/actual_db_schema/schema_parser.rb
196
201
  - lib/actual_db_schema/store.rb
197
202
  - lib/actual_db_schema/version.rb