actual_db_schema 0.9.0 → 0.9.1
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 +4 -4
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile.lock +2 -2
- data/README.md +77 -0
- data/app/controllers/actual_db_schema/schema_controller.rb +2 -1
- data/lib/actual_db_schema/configuration.rb +34 -7
- data/lib/actual_db_schema/engine.rb +47 -9
- data/lib/actual_db_schema/instrumentation.rb +7 -0
- data/lib/actual_db_schema/migration_context.rb +22 -5
- data/lib/actual_db_schema/patches/migration_context.rb +21 -3
- data/lib/actual_db_schema/rollback_stats_repository.rb +102 -0
- data/lib/actual_db_schema/schema_diff.rb +40 -8
- data/lib/actual_db_schema/schema_diff_html.rb +11 -5
- data/lib/actual_db_schema/structure_sql_parser.rb +41 -0
- data/lib/actual_db_schema/version.rb +1 -1
- data/lib/actual_db_schema.rb +3 -0
- data/lib/generators/actual_db_schema/templates/actual_db_schema.rb +9 -0
- data/lib/tasks/actual_db_schema.rake +2 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a1fd777a52ad2881a451c80498693f36267944c4fe1c1b6796582900ce968224
|
|
4
|
+
data.tar.gz: d051372a40ec8002d72ce969f30cb93f02f09c4cd60b3c151922caa501d21687
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6b16965816aff4dbc9b5cdaa5422a6cb89a609f577d6f5dfc4b93df541067d3d111c1d66decc489a312d4e602460b86e32036ce1c3653fdb477a5075b2fa85c
|
|
7
|
+
data.tar.gz: 6c133a90fd82909e1beaac3db9735e98b5d790315a115e190880a428a5cba37b76921549884e947cc67667be5678dd8bc4c9e9bee9fded042be466331651dc2e
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [0.9.1] - 2026-02-25
|
|
2
|
+
|
|
3
|
+
- Support schema diffs for `structure.sql`
|
|
4
|
+
- Add an option to exclude specific databases from the gem's visibility scope
|
|
5
|
+
- Fix a crash when the database is not available at application startup
|
|
6
|
+
- Add instrumentation tooling to track stats about rolled-back phantom migrations
|
|
7
|
+
|
|
1
8
|
## [0.9.0] - 2026-01-27
|
|
2
9
|
- Store migration files in the DB to avoid reliance on the filesystem, enabling CI/CD usage on platforms with ephemeral storage (e.g., Heroku, Docker).
|
|
3
10
|
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
actual_db_schema (0.9.
|
|
4
|
+
actual_db_schema (0.9.1)
|
|
5
5
|
activerecord
|
|
6
6
|
activesupport
|
|
7
7
|
ast
|
|
@@ -146,7 +146,7 @@ GEM
|
|
|
146
146
|
parser (3.2.2.4)
|
|
147
147
|
ast (~> 2.4.1)
|
|
148
148
|
racc
|
|
149
|
-
prism (1.
|
|
149
|
+
prism (1.9.0)
|
|
150
150
|
psych (5.1.1.1)
|
|
151
151
|
stringio
|
|
152
152
|
racc (1.7.3)
|
data/README.md
CHANGED
|
@@ -230,6 +230,50 @@ Add the following line to your initializer file (`config/initializers/actual_db_
|
|
|
230
230
|
config.auto_rollback_disabled = true
|
|
231
231
|
```
|
|
232
232
|
|
|
233
|
+
## Rollback Instrumentation
|
|
234
|
+
|
|
235
|
+
ActualDbSchema emits an `ActiveSupport::Notifications` event for each successful phantom rollback:
|
|
236
|
+
|
|
237
|
+
- Event name: `rollback_migration.actual_db_schema`
|
|
238
|
+
- Event is always emitted when a phantom rollback succeeds
|
|
239
|
+
|
|
240
|
+
### Event payload
|
|
241
|
+
|
|
242
|
+
| Field | Description |
|
|
243
|
+
|-------|-------------|
|
|
244
|
+
| `version` | Migration version that was rolled back |
|
|
245
|
+
| `name` | Migration class name |
|
|
246
|
+
| `database` | Current database name from Active Record config |
|
|
247
|
+
| `schema` | Tenant schema name (or `nil` for default schema) |
|
|
248
|
+
| `branch` | Branch associated with the migration metadata |
|
|
249
|
+
| `manual_mode` | Whether rollback was run in manual mode |
|
|
250
|
+
|
|
251
|
+
### Subscribing to rollback events
|
|
252
|
+
|
|
253
|
+
You can subscribe to rollback events in your initializer to track statistics or perform custom actions:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# config/initializers/actual_db_schema.rb
|
|
257
|
+
ActiveSupport::Notifications.subscribe(ActualDbSchema::Instrumentation::ROLLBACK_EVENT) do |_name, _start, _finish, _id, payload|
|
|
258
|
+
ActualDbSchema::RollbackStatsRepository.record(payload)
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The `RollbackStatsRepository` persists rollback events to a database table (`actual_db_schema_rollback_events`) that is automatically excluded from schema dumps.
|
|
263
|
+
|
|
264
|
+
Read aggregated stats at runtime:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
ActualDbSchema::RollbackStatsRepository.stats
|
|
268
|
+
# => { total: 3, by_database: { "primary" => 3 }, by_schema: { "default" => 3 }, by_branch: { "main" => 3 } }
|
|
269
|
+
|
|
270
|
+
ActualDbSchema::RollbackStatsRepository.total_rollbacks
|
|
271
|
+
# => 3
|
|
272
|
+
|
|
273
|
+
ActualDbSchema::RollbackStatsRepository.reset!
|
|
274
|
+
# Clears all recorded stats
|
|
275
|
+
```
|
|
276
|
+
|
|
233
277
|
## Automatic Phantom Migration Rollback On Branch Switch
|
|
234
278
|
|
|
235
279
|
By default, the automatic rollback of migrations on branch switch is disabled. If you prefer to automatically rollback phantom migrations whenever you switch branches with `git checkout`, you can enable it in two ways:
|
|
@@ -264,6 +308,39 @@ This task will prompt you to choose one of the three options:
|
|
|
264
308
|
|
|
265
309
|
Based on your selection, a post-checkout hook will be installed or updated in your `.git/hooks` folder.
|
|
266
310
|
|
|
311
|
+
## Excluding Databases from Processing
|
|
312
|
+
|
|
313
|
+
**For Rails 6.1+ applications using multiple databases** (especially with infrastructure databases like Solid Queue, Solid Cable, or Solid Cache), you can exclude specific databases from ActualDbSchema's processing to prevent connection conflicts.
|
|
314
|
+
|
|
315
|
+
### Why You Might Need This
|
|
316
|
+
|
|
317
|
+
Modern Rails applications often use the `connects_to` pattern for infrastructure databases. These databases maintain their own isolated connection pools, and ActualDbSchema's global connection switching can interfere with active queries. This is particularly common with:
|
|
318
|
+
|
|
319
|
+
- **Solid Queue** (Rails 8 default job backend)
|
|
320
|
+
- **Solid Cable** (WebSocket connections)
|
|
321
|
+
- **Solid Cache** (caching infrastructure)
|
|
322
|
+
|
|
323
|
+
### Method 1: Using `excluded_databases` Configuration
|
|
324
|
+
|
|
325
|
+
Explicitly exclude databases by name in your initializer:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
# config/initializers/actual_db_schema.rb
|
|
329
|
+
ActualDbSchema.configure do |config|
|
|
330
|
+
config.excluded_databases = [:queue, :cable, :cache]
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Method 2: Using Environment Variable
|
|
335
|
+
|
|
336
|
+
Set the environment variable `ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES` with a comma-separated list:
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
export ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES="queue,cable,cache"
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Note:** If both the environment variable and the configuration setting in the initializer are provided, the configuration setting takes precedence as it's applied after the default settings are loaded.
|
|
343
|
+
|
|
267
344
|
## Multi-Tenancy Support
|
|
268
345
|
|
|
269
346
|
If your application leverages multiple schemas for multi-tenancy — such as those implemented by the [apartment](https://github.com/influitive/apartment) gem or similar solutions — you can configure ActualDbSchema to handle migrations across all schemas. To do so, add the following configuration to your initializer file (`config/initializers/actual_db_schema.rb`):
|
|
@@ -11,7 +11,8 @@ module ActualDbSchema
|
|
|
11
11
|
private
|
|
12
12
|
|
|
13
13
|
helper_method def schema_diff_html
|
|
14
|
-
|
|
14
|
+
schema_path = Rails.configuration.active_record.schema_format == :sql ? "./db/structure.sql" : "./db/schema.rb"
|
|
15
|
+
schema_diff = ActualDbSchema::SchemaDiffHtml.new(schema_path, "db/migrate")
|
|
15
16
|
schema_diff.render_html(params[:table])
|
|
16
17
|
end
|
|
17
18
|
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, :migrated_folder, :migrations_storage
|
|
7
|
+
:console_migrations_enabled, :migrated_folder, :migrations_storage, :excluded_databases
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
apply_defaults(default_settings)
|
|
@@ -33,17 +33,44 @@ module ActualDbSchema
|
|
|
33
33
|
|
|
34
34
|
def default_settings
|
|
35
35
|
{
|
|
36
|
-
enabled:
|
|
37
|
-
auto_rollback_disabled:
|
|
38
|
-
ui_enabled:
|
|
39
|
-
git_hooks_enabled:
|
|
36
|
+
enabled: enabled_by_default?,
|
|
37
|
+
auto_rollback_disabled: env_enabled?("ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"),
|
|
38
|
+
ui_enabled: ui_enabled_by_default?,
|
|
39
|
+
git_hooks_enabled: env_enabled?("ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"),
|
|
40
40
|
multi_tenant_schemas: nil,
|
|
41
|
-
console_migrations_enabled:
|
|
41
|
+
console_migrations_enabled: env_enabled?("ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"),
|
|
42
42
|
migrated_folder: ENV["ACTUAL_DB_SCHEMA_MIGRATED_FOLDER"].present?,
|
|
43
|
-
migrations_storage:
|
|
43
|
+
migrations_storage: migrations_storage_from_env,
|
|
44
|
+
excluded_databases: parse_excluded_databases_env
|
|
44
45
|
}
|
|
45
46
|
end
|
|
46
47
|
|
|
48
|
+
def enabled_by_default?
|
|
49
|
+
Rails.env.development?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ui_enabled_by_default?
|
|
53
|
+
Rails.env.development? || env_enabled?("ACTUAL_DB_SCHEMA_UI_ENABLED")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def env_enabled?(key)
|
|
57
|
+
ENV[key].present?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def migrations_storage_from_env
|
|
61
|
+
ENV.fetch("ACTUAL_DB_SCHEMA_MIGRATIONS_STORAGE", "file").to_sym
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_excluded_databases_env
|
|
65
|
+
return [] unless ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"].present?
|
|
66
|
+
|
|
67
|
+
ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"]
|
|
68
|
+
.split(",")
|
|
69
|
+
.map(&:strip)
|
|
70
|
+
.reject(&:empty?)
|
|
71
|
+
.map(&:to_sym)
|
|
72
|
+
end
|
|
73
|
+
|
|
47
74
|
def apply_defaults(settings)
|
|
48
75
|
settings.each do |key, value|
|
|
49
76
|
instance_variable_set("@#{key}", value)
|
|
@@ -15,21 +15,51 @@ module ActualDbSchema
|
|
|
15
15
|
|
|
16
16
|
initializer "actual_db_schema.schema_dump_exclusions" do
|
|
17
17
|
ActiveSupport.on_load(:active_record) do
|
|
18
|
-
|
|
18
|
+
ActualDbSchema::Engine.apply_schema_dump_exclusions
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.apply_schema_dump_exclusions
|
|
23
|
+
ignore_schema_dump_table(ActualDbSchema::Store::DbAdapter::TABLE_NAME)
|
|
24
|
+
ignore_schema_dump_table(ActualDbSchema::RollbackStatsRepository::TABLE_NAME)
|
|
25
|
+
return unless schema_dump_flags_supported?
|
|
26
|
+
return unless schema_dump_connection_available?
|
|
27
|
+
|
|
28
|
+
apply_structure_dump_flags(ActualDbSchema::Store::DbAdapter::TABLE_NAME)
|
|
29
|
+
apply_structure_dump_flags(ActualDbSchema::RollbackStatsRepository::TABLE_NAME)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def ignore_schema_dump_table(table_name)
|
|
36
|
+
return unless defined?(ActiveRecord::SchemaDumper)
|
|
37
|
+
return unless ActiveRecord::SchemaDumper.respond_to?(:ignore_tables)
|
|
38
|
+
|
|
39
|
+
ActiveRecord::SchemaDumper.ignore_tables |= [table_name]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def schema_dump_flags_supported?
|
|
43
|
+
defined?(ActiveRecord::Tasks::DatabaseTasks) &&
|
|
44
|
+
ActiveRecord::Tasks::DatabaseTasks.respond_to?(:structure_dump_flags)
|
|
45
|
+
end
|
|
19
46
|
|
|
20
|
-
|
|
21
|
-
|
|
47
|
+
# Avoid touching db config unless we explicitly use DB storage
|
|
48
|
+
# or a connection is already available.
|
|
49
|
+
def schema_dump_connection_available?
|
|
50
|
+
has_connection = begin
|
|
51
|
+
ActiveRecord::Base.connection_pool.connected?
|
|
52
|
+
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
|
|
53
|
+
false
|
|
22
54
|
end
|
|
23
55
|
|
|
24
|
-
|
|
25
|
-
|
|
56
|
+
ActualDbSchema.config[:migrations_storage] == :db || has_connection
|
|
57
|
+
end
|
|
26
58
|
|
|
59
|
+
def apply_structure_dump_flags(table_name)
|
|
27
60
|
flags = Array(ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags)
|
|
28
61
|
adapter = ActualDbSchema.db_config[:adapter].to_s
|
|
29
|
-
database =
|
|
30
|
-
if database.nil? && ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
31
|
-
database = ActiveRecord::Base.connection_db_config&.database
|
|
32
|
-
end
|
|
62
|
+
database = database_name
|
|
33
63
|
|
|
34
64
|
if adapter.match?(/postgres/i)
|
|
35
65
|
flag = "--exclude-table=#{table_name}*"
|
|
@@ -41,6 +71,14 @@ module ActualDbSchema
|
|
|
41
71
|
|
|
42
72
|
ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags
|
|
43
73
|
end
|
|
74
|
+
|
|
75
|
+
def database_name
|
|
76
|
+
database = ActualDbSchema.db_config[:database]
|
|
77
|
+
if database.nil? && ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
78
|
+
database = ActiveRecord::Base.connection_db_config&.database
|
|
79
|
+
end
|
|
80
|
+
database
|
|
81
|
+
end
|
|
44
82
|
end
|
|
45
83
|
end
|
|
46
84
|
end
|
|
@@ -20,11 +20,28 @@ module ActualDbSchema
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def configs
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
all_configs = if ActiveRecord::Base.configurations.is_a?(Hash)
|
|
24
|
+
# Rails < 6.0 has a Hash in configurations
|
|
25
|
+
[ActiveRecord::Base.configurations[ActiveRecord::Tasks::DatabaseTasks.env]]
|
|
26
|
+
else
|
|
27
|
+
ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
filter_configs(all_configs)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def filter_configs(all_configs)
|
|
34
|
+
all_configs.reject do |db_config|
|
|
35
|
+
# Skip if database is in the excluded list
|
|
36
|
+
# Rails 6.0 uses spec_name, Rails 6.1+ uses name
|
|
37
|
+
db_name = if db_config.respond_to?(:name)
|
|
38
|
+
db_config.name.to_sym
|
|
39
|
+
elsif db_config.respond_to?(:spec_name)
|
|
40
|
+
db_config.spec_name.to_sym
|
|
41
|
+
else
|
|
42
|
+
:primary
|
|
43
|
+
end
|
|
44
|
+
ActualDbSchema.config.excluded_databases.include?(db_name)
|
|
28
45
|
end
|
|
29
46
|
end
|
|
30
47
|
|
|
@@ -37,7 +37,10 @@ module ActualDbSchema
|
|
|
37
37
|
next unless status_up?(migration)
|
|
38
38
|
|
|
39
39
|
show_info_for(migration, schema_name) if manual_mode
|
|
40
|
-
|
|
40
|
+
if !manual_mode || user_wants_rollback?
|
|
41
|
+
migrate(migration, rolled_back_migrations, schema_name,
|
|
42
|
+
manual_mode: manual_mode)
|
|
43
|
+
end
|
|
41
44
|
rescue StandardError => e
|
|
42
45
|
handle_rollback_error(migration, e, schema_name)
|
|
43
46
|
end
|
|
@@ -103,21 +106,36 @@ module ActualDbSchema
|
|
|
103
106
|
puts File.read(migration.filename)
|
|
104
107
|
end
|
|
105
108
|
|
|
106
|
-
def migrate(migration, rolled_back_migrations, schema_name = nil)
|
|
109
|
+
def migrate(migration, rolled_back_migrations, schema_name = nil, manual_mode: false)
|
|
107
110
|
migration.name = extract_class_name(migration.filename)
|
|
108
111
|
|
|
112
|
+
branch = branch_for(migration.version.to_s)
|
|
109
113
|
message = "[ActualDbSchema]"
|
|
110
114
|
message += " #{schema_name}:" if schema_name
|
|
111
115
|
message += " Rolling back phantom migration #{migration.version} #{migration.name} " \
|
|
112
|
-
"(from branch: #{
|
|
116
|
+
"(from branch: #{branch})"
|
|
113
117
|
puts colorize(message, :gray)
|
|
114
118
|
|
|
115
119
|
migrator = down_migrator_for(migration)
|
|
116
120
|
migrator.extend(ActualDbSchema::Patches::Migrator)
|
|
117
121
|
migrator.migrate
|
|
122
|
+
notify_rollback_migration(migration: migration, schema_name: schema_name, branch: branch,
|
|
123
|
+
manual_mode: manual_mode)
|
|
118
124
|
rolled_back_migrations << migration
|
|
119
125
|
end
|
|
120
126
|
|
|
127
|
+
def notify_rollback_migration(migration:, schema_name:, branch:, manual_mode:)
|
|
128
|
+
ActiveSupport::Notifications.instrument(
|
|
129
|
+
ActualDbSchema::Instrumentation::ROLLBACK_EVENT,
|
|
130
|
+
version: migration.version.to_s,
|
|
131
|
+
name: migration.name,
|
|
132
|
+
database: ActualDbSchema.db_config[:database],
|
|
133
|
+
schema: schema_name,
|
|
134
|
+
branch: branch,
|
|
135
|
+
manual_mode: manual_mode
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
121
139
|
def extract_class_name(filename)
|
|
122
140
|
content = File.read(filename)
|
|
123
141
|
content.match(/^class\s+([A-Za-z0-9_]+)\s+</)[1]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActualDbSchema
|
|
4
|
+
# Persists rollback events in DB.
|
|
5
|
+
class RollbackStatsRepository
|
|
6
|
+
TABLE_NAME = "actual_db_schema_rollback_events"
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def record(payload)
|
|
10
|
+
ensure_table!
|
|
11
|
+
connection.execute(<<~SQL.squish)
|
|
12
|
+
INSERT INTO #{quoted_table}
|
|
13
|
+
(#{quoted_column("version")}, #{quoted_column("name")}, #{quoted_column("database")},
|
|
14
|
+
#{quoted_column("schema")}, #{quoted_column("branch")}, #{quoted_column("manual_mode")},
|
|
15
|
+
#{quoted_column("created_at")})
|
|
16
|
+
VALUES
|
|
17
|
+
(#{connection.quote(payload[:version].to_s)}, #{connection.quote(payload[:name].to_s)},
|
|
18
|
+
#{connection.quote(payload[:database].to_s)}, #{connection.quote((payload[:schema] || "default").to_s)},
|
|
19
|
+
#{connection.quote(payload[:branch].to_s)}, #{connection.quote(!!payload[:manual_mode])},
|
|
20
|
+
#{connection.quote(Time.current)})
|
|
21
|
+
SQL
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stats
|
|
25
|
+
return empty_stats unless table_exists?
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
total: total_rollbacks,
|
|
29
|
+
by_database: aggregate_by(:database),
|
|
30
|
+
by_schema: aggregate_by(:schema),
|
|
31
|
+
by_branch: aggregate_by(:branch)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def total_rollbacks
|
|
36
|
+
return 0 unless table_exists?
|
|
37
|
+
|
|
38
|
+
connection.select_value(<<~SQL.squish).to_i
|
|
39
|
+
SELECT COUNT(*) FROM #{quoted_table}
|
|
40
|
+
SQL
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reset!
|
|
44
|
+
return unless table_exists?
|
|
45
|
+
|
|
46
|
+
connection.execute("DELETE FROM #{quoted_table}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def ensure_table!
|
|
52
|
+
return if table_exists?
|
|
53
|
+
|
|
54
|
+
connection.create_table(TABLE_NAME) do |t|
|
|
55
|
+
t.string :version, null: false
|
|
56
|
+
t.string :name
|
|
57
|
+
t.string :database, null: false
|
|
58
|
+
t.string :schema
|
|
59
|
+
t.string :branch, null: false
|
|
60
|
+
t.boolean :manual_mode, null: false, default: false
|
|
61
|
+
t.datetime :created_at, null: false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def table_exists?
|
|
66
|
+
connection.table_exists?(TABLE_NAME)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def aggregate_by(column)
|
|
70
|
+
return {} unless table_exists?
|
|
71
|
+
|
|
72
|
+
rows = connection.select_all(<<~SQL.squish)
|
|
73
|
+
SELECT #{quoted_column(column)}, COUNT(*) AS cnt
|
|
74
|
+
FROM #{quoted_table}
|
|
75
|
+
GROUP BY #{quoted_column(column)}
|
|
76
|
+
SQL
|
|
77
|
+
rows.each_with_object(Hash.new(0)) { |row, h| h[row[column.to_s].to_s] = row["cnt"].to_i }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def empty_stats
|
|
81
|
+
{
|
|
82
|
+
total: 0,
|
|
83
|
+
by_database: {},
|
|
84
|
+
by_schema: {},
|
|
85
|
+
by_branch: {}
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def connection
|
|
90
|
+
ActiveRecord::Base.connection
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def quoted_table
|
|
94
|
+
connection.quote_table_name(TABLE_NAME)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def quoted_column(name)
|
|
98
|
+
connection.quote_column_name(name)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -19,6 +19,14 @@ module ActualDbSchema
|
|
|
19
19
|
/create_table\s+["']([^"']+)["']/ => :table
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
+
SQL_CHANGE_PATTERNS = {
|
|
23
|
+
/CREATE (?:UNIQUE\s+)?INDEX\s+["']?([^"'\s]+)["']?\s+ON\s+([\w.]+)/i => :index,
|
|
24
|
+
/CREATE TABLE\s+(\S+)\s+\(/i => :table,
|
|
25
|
+
/CREATE SEQUENCE\s+(\S+)/i => :table,
|
|
26
|
+
/ALTER SEQUENCE\s+(\S+)\s+OWNED BY\s+([\w.]+)/i => :table,
|
|
27
|
+
/ALTER TABLE\s+ONLY\s+(\S+)\s+/i => :table
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
22
30
|
def initialize(schema_path, migrations_path)
|
|
23
31
|
@schema_path = schema_path
|
|
24
32
|
@migrations_path = migrations_path
|
|
@@ -48,11 +56,19 @@ module ActualDbSchema
|
|
|
48
56
|
end
|
|
49
57
|
|
|
50
58
|
def parsed_old_schema
|
|
51
|
-
@parsed_old_schema ||=
|
|
59
|
+
@parsed_old_schema ||= parser_class.parse_string(old_schema_content.to_s)
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
def parsed_new_schema
|
|
55
|
-
@parsed_new_schema ||=
|
|
63
|
+
@parsed_new_schema ||= parser_class.parse_string(new_schema_content.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parser_class
|
|
67
|
+
structure_sql? ? StructureSqlParser : SchemaParser
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def structure_sql?
|
|
71
|
+
File.extname(@schema_path) == ".sql"
|
|
56
72
|
end
|
|
57
73
|
|
|
58
74
|
def migration_changes
|
|
@@ -105,8 +121,9 @@ module ActualDbSchema
|
|
|
105
121
|
lines.each do |line|
|
|
106
122
|
if (hunk_match = line.match(/^@@\s+-(\d+),(\d+)\s+\+(\d+),(\d+)\s+@@/))
|
|
107
123
|
current_table = find_table_in_new_schema(hunk_match[3].to_i)
|
|
108
|
-
elsif (ct = line.match(/create_table\s+["']([^"']+)["']/)
|
|
109
|
-
|
|
124
|
+
elsif (ct = line.match(/create_table\s+["']([^"']+)["']/) ||
|
|
125
|
+
line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i) || line.match(/ALTER TABLE\s+ONLY\s+(\S+)/i))
|
|
126
|
+
current_table = normalize_table_name(ct[1])
|
|
110
127
|
end
|
|
111
128
|
|
|
112
129
|
result_lines << (%w[+ -].include?(line[0]) ? handle_diff_line(line, current_table) : line)
|
|
@@ -128,19 +145,24 @@ module ActualDbSchema
|
|
|
128
145
|
end
|
|
129
146
|
|
|
130
147
|
def detect_action_and_name(line_content, sign, current_table)
|
|
148
|
+
patterns = structure_sql? ? SQL_CHANGE_PATTERNS : CHANGE_PATTERNS
|
|
131
149
|
action_map = {
|
|
132
150
|
column: ->(md) { [guess_action(sign, current_table, md[2]), md[2]] },
|
|
133
151
|
index: ->(md) { [sign == "+" ? :add_index : :remove_index, md[1]] },
|
|
134
152
|
table: ->(_) { [sign == "+" ? :create_table : :drop_table, nil] }
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
|
|
155
|
+
patterns.each do |regex, kind|
|
|
138
156
|
next unless (md = line_content.match(regex))
|
|
139
157
|
|
|
140
158
|
action_proc = action_map[kind]
|
|
141
159
|
return action_proc.call(md) if action_proc
|
|
142
160
|
end
|
|
143
161
|
|
|
162
|
+
if structure_sql? && current_table && (md = line_content.match(/^\s*"?(\w+)"?\s+(.+?)(?:,|\s*$)/i))
|
|
163
|
+
return [guess_action(sign, current_table, md[1]), md[1]]
|
|
164
|
+
end
|
|
165
|
+
|
|
144
166
|
[nil, nil]
|
|
145
167
|
end
|
|
146
168
|
|
|
@@ -159,8 +181,8 @@ module ActualDbSchema
|
|
|
159
181
|
current_table = nil
|
|
160
182
|
|
|
161
183
|
new_schema_content.lines[0...new_line_number].each do |line|
|
|
162
|
-
if (match = line.match(/create_table\s+["']([^"']+)["']/))
|
|
163
|
-
current_table = match[1]
|
|
184
|
+
if (match = line.match(/create_table\s+["']([^"']+)["']/) || line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i))
|
|
185
|
+
current_table = normalize_table_name(match[1])
|
|
164
186
|
end
|
|
165
187
|
end
|
|
166
188
|
current_table
|
|
@@ -171,7 +193,7 @@ module ActualDbSchema
|
|
|
171
193
|
|
|
172
194
|
migration_changes.each do |file_path, changes|
|
|
173
195
|
changes.each do |chg|
|
|
174
|
-
next unless chg[:table].to_s == table_name.to_s
|
|
196
|
+
next unless (structure_sql? && index_action?(action)) || chg[:table].to_s == table_name.to_s
|
|
175
197
|
|
|
176
198
|
matches << file_path if migration_matches?(chg, action, col_or_index_name)
|
|
177
199
|
end
|
|
@@ -180,6 +202,10 @@ module ActualDbSchema
|
|
|
180
202
|
matches
|
|
181
203
|
end
|
|
182
204
|
|
|
205
|
+
def index_action?(action)
|
|
206
|
+
%i[add_index remove_index rename_index].include?(action)
|
|
207
|
+
end
|
|
208
|
+
|
|
183
209
|
def migration_matches?(chg, action, col_or_index_name)
|
|
184
210
|
return (chg[:action] == action) if col_or_index_name.nil?
|
|
185
211
|
|
|
@@ -225,5 +251,11 @@ module ActualDbSchema
|
|
|
225
251
|
def annotate_line(line, migration_file_paths)
|
|
226
252
|
"#{line.chomp}#{colorize(" // #{migration_file_paths.join(", ")} //", :gray)}\n"
|
|
227
253
|
end
|
|
254
|
+
|
|
255
|
+
def normalize_table_name(table_name)
|
|
256
|
+
return table_name unless structure_sql? && table_name.include?(".")
|
|
257
|
+
|
|
258
|
+
table_name.split(".").last
|
|
259
|
+
end
|
|
228
260
|
end
|
|
229
261
|
end
|
|
@@ -17,7 +17,7 @@ module ActualDbSchema
|
|
|
17
17
|
|
|
18
18
|
def generate_diff_html
|
|
19
19
|
diff_output = generate_full_diff(old_schema_content, new_schema_content)
|
|
20
|
-
|
|
20
|
+
diff_output = new_schema_content if diff_output.strip.empty?
|
|
21
21
|
|
|
22
22
|
process_diff_output_for_html(diff_output)
|
|
23
23
|
end
|
|
@@ -43,7 +43,7 @@ module ActualDbSchema
|
|
|
43
43
|
block_depth = 1
|
|
44
44
|
|
|
45
45
|
diff_str.lines.each do |line|
|
|
46
|
-
next if
|
|
46
|
+
next if skip_line?(line)
|
|
47
47
|
|
|
48
48
|
current_table, table_start, block_depth =
|
|
49
49
|
process_table(line, current_table, table_start, result_lines.size, block_depth)
|
|
@@ -53,15 +53,21 @@ module ActualDbSchema
|
|
|
53
53
|
result_lines.join
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def skip_line?(line)
|
|
57
|
+
line != "---\n" && !line.match(/^--- Name/) &&
|
|
58
|
+
(line.start_with?("---") || line.start_with?("+++") || line.match(/^@@/))
|
|
59
|
+
end
|
|
60
|
+
|
|
56
61
|
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]
|
|
62
|
+
if (ct = line.match(/create_table\s+["']([^"']+)["']/) || line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i))
|
|
63
|
+
return [normalize_table_name(ct[1]), table_end, block_depth]
|
|
59
64
|
end
|
|
60
65
|
|
|
61
66
|
return [current_table, table_start, block_depth] unless current_table
|
|
62
67
|
|
|
63
68
|
block_depth += line.scan(/\bdo\b/).size unless line.match(/create_table\s+["']([^"']+)["']/)
|
|
64
69
|
block_depth -= line.scan(/\bend\b/).size
|
|
70
|
+
block_depth -= line.scan(/\);\s*$/).size
|
|
65
71
|
|
|
66
72
|
if block_depth.zero?
|
|
67
73
|
@tables[current_table] = { start: table_start, end: table_end }
|
|
@@ -101,7 +107,7 @@ module ActualDbSchema
|
|
|
101
107
|
end
|
|
102
108
|
|
|
103
109
|
def link_to_migration(migration_file_path)
|
|
104
|
-
migration = migrations.detect { |m| m.filename == migration_file_path }
|
|
110
|
+
migration = migrations.detect { |m| File.expand_path(m.filename) == File.expand_path(migration_file_path) }
|
|
105
111
|
return ERB::Util.html_escape(migration_file_path) unless migration
|
|
106
112
|
|
|
107
113
|
url = "migrations/#{migration.version}?database=#{migration.database}"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActualDbSchema
|
|
4
|
+
# Parses the content of a `structure.sql` file into a structured hash representation.
|
|
5
|
+
module StructureSqlParser
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse_string(sql_content)
|
|
9
|
+
schema = {}
|
|
10
|
+
table_regex = /CREATE TABLE\s+(?:"?([\w.]+)"?)\s*\((.*?)\);/m
|
|
11
|
+
sql_content.scan(table_regex) do |table_name, columns_section|
|
|
12
|
+
schema[normalize_table_name(table_name)] = parse_columns(columns_section)
|
|
13
|
+
end
|
|
14
|
+
schema
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse_columns(columns_section)
|
|
18
|
+
columns = {}
|
|
19
|
+
columns_section.each_line do |line|
|
|
20
|
+
line.strip!
|
|
21
|
+
next if line.empty? || line =~ /^(CONSTRAINT|PRIMARY KEY|FOREIGN KEY)/i
|
|
22
|
+
|
|
23
|
+
match = line.match(/\A"?(?<col>\w+)"?\s+(?<type>\w+)(?<size>\s*\([\d,]+\))?/i)
|
|
24
|
+
next unless match
|
|
25
|
+
|
|
26
|
+
col_name = match[:col]
|
|
27
|
+
col_type = match[:type].strip.downcase.to_sym
|
|
28
|
+
options = {}
|
|
29
|
+
columns[col_name] = { type: col_type, options: options }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
columns
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_table_name(table_name)
|
|
36
|
+
return table_name unless table_name.include?(".")
|
|
37
|
+
|
|
38
|
+
table_name.split(".").last
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/actual_db_schema.rb
CHANGED
|
@@ -4,7 +4,9 @@ require "actual_db_schema/engine"
|
|
|
4
4
|
require "active_record/migration"
|
|
5
5
|
require "csv"
|
|
6
6
|
require_relative "actual_db_schema/git"
|
|
7
|
+
require_relative "actual_db_schema/rollback_stats_repository"
|
|
7
8
|
require_relative "actual_db_schema/configuration"
|
|
9
|
+
require_relative "actual_db_schema/instrumentation"
|
|
8
10
|
require_relative "actual_db_schema/store"
|
|
9
11
|
require_relative "actual_db_schema/version"
|
|
10
12
|
require_relative "actual_db_schema/migration"
|
|
@@ -21,6 +23,7 @@ require_relative "actual_db_schema/railtie"
|
|
|
21
23
|
require_relative "actual_db_schema/schema_diff"
|
|
22
24
|
require_relative "actual_db_schema/schema_diff_html"
|
|
23
25
|
require_relative "actual_db_schema/schema_parser"
|
|
26
|
+
require_relative "actual_db_schema/structure_sql_parser"
|
|
24
27
|
|
|
25
28
|
require_relative "actual_db_schema/commands/base"
|
|
26
29
|
require_relative "actual_db_schema/commands/rollback"
|
|
@@ -35,4 +35,13 @@ if defined?(ActualDbSchema)
|
|
|
35
35
|
# config.migrations_storage = :db
|
|
36
36
|
config.migrations_storage = :file
|
|
37
37
|
end
|
|
38
|
+
|
|
39
|
+
# Subscribe to rollback events to persist stats (optional).
|
|
40
|
+
# Uncomment the following to track rollback statistics in the database:
|
|
41
|
+
#
|
|
42
|
+
# ActiveSupport::Notifications.subscribe(
|
|
43
|
+
# ActualDbSchema::Instrumentation::ROLLBACK_EVENT
|
|
44
|
+
# ) do |_name, _start, _finish, _id, payload|
|
|
45
|
+
# ActualDbSchema::RollbackStatsRepository.record(payload)
|
|
46
|
+
# end
|
|
38
47
|
end
|
|
@@ -56,7 +56,8 @@ namespace :actual_db_schema do # rubocop:disable Metrics/BlockLength
|
|
|
56
56
|
|
|
57
57
|
desc "Show the schema.rb diff annotated with the migrations that made the changes"
|
|
58
58
|
task :diff_schema_with_migrations, %i[schema_path migrations_path] => :environment do |_, args|
|
|
59
|
-
|
|
59
|
+
default_schema = Rails.configuration.active_record.schema_format == :sql ? "./db/structure.sql" : "./db/schema.rb"
|
|
60
|
+
schema_path = args[:schema_path] || default_schema
|
|
60
61
|
migrations_path = args[:migrations_path] || "db/migrate"
|
|
61
62
|
|
|
62
63
|
schema_diff = ActualDbSchema::SchemaDiff.new(schema_path, migrations_path)
|
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.9.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Kaleshka
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-02-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -201,6 +201,7 @@ files:
|
|
|
201
201
|
- lib/actual_db_schema/failed_migration.rb
|
|
202
202
|
- lib/actual_db_schema/git.rb
|
|
203
203
|
- lib/actual_db_schema/git_hooks.rb
|
|
204
|
+
- lib/actual_db_schema/instrumentation.rb
|
|
204
205
|
- lib/actual_db_schema/migration.rb
|
|
205
206
|
- lib/actual_db_schema/migration_context.rb
|
|
206
207
|
- lib/actual_db_schema/migration_parser.rb
|
|
@@ -210,10 +211,12 @@ files:
|
|
|
210
211
|
- lib/actual_db_schema/patches/migration_proxy.rb
|
|
211
212
|
- lib/actual_db_schema/patches/migrator.rb
|
|
212
213
|
- lib/actual_db_schema/railtie.rb
|
|
214
|
+
- lib/actual_db_schema/rollback_stats_repository.rb
|
|
213
215
|
- lib/actual_db_schema/schema_diff.rb
|
|
214
216
|
- lib/actual_db_schema/schema_diff_html.rb
|
|
215
217
|
- lib/actual_db_schema/schema_parser.rb
|
|
216
218
|
- lib/actual_db_schema/store.rb
|
|
219
|
+
- lib/actual_db_schema/structure_sql_parser.rb
|
|
217
220
|
- lib/actual_db_schema/version.rb
|
|
218
221
|
- lib/generators/actual_db_schema/templates/actual_db_schema.rb
|
|
219
222
|
- lib/tasks/actual_db_schema.rake
|