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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d7f7a42aa9ad38de5eeb7a64ca7a8a9117485a927d536f666aa2bf41be97b9f
4
- data.tar.gz: 2236fd011c7aa72be7ffeef9d975da3bde1d6dd6844366cb458f51afa479395e
3
+ metadata.gz: a1fd777a52ad2881a451c80498693f36267944c4fe1c1b6796582900ce968224
4
+ data.tar.gz: d051372a40ec8002d72ce969f30cb93f02f09c4cd60b3c151922caa501d21687
5
5
  SHA512:
6
- metadata.gz: 3ed9ec959e789377596103e2be9214d5374551bbba0ab17a037cd47bc6481f035adf1b7a24bc2254702d8a64eead1fc503ee4d2317a55596258d7284500f5fd3
7
- data.tar.gz: 48fc36d62e62e810d6d1fd4c8e3b578654587ac13f9ad8e0245e3e08c5cecf1a683ae3292715b366d0d5ce5b11cebcf953963aed8460664ddd7b0af9d7f1af7b
6
+ metadata.gz: b6b16965816aff4dbc9b5cdaa5422a6cb89a609f577d6f5dfc4b93df541067d3d111c1d66decc489a312d4e602460b86e32036ce1c3653fdb477a5075b2fa85c
7
+ data.tar.gz: 6c133a90fd82909e1beaac3db9735e98b5d790315a115e190880a428a5cba37b76921549884e947cc67667be5678dd8bc4c9e9bee9fded042be466331651dc2e
data/.rubocop.yml CHANGED
@@ -31,3 +31,15 @@ Metrics/ClassLength:
31
31
 
32
32
  Metrics/ModuleLength:
33
33
  Enabled: false
34
+
35
+ Metrics/AbcSize:
36
+ Max: 25
37
+
38
+ Metrics/CyclomaticComplexity:
39
+ Max: 10
40
+
41
+ Metrics/PerceivedComplexity:
42
+ Max: 10
43
+
44
+ Metrics/AbcSize:
45
+ Enabled: false
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.0)
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.8.0)
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
- schema_diff = ActualDbSchema::SchemaDiffHtml.new("./db/schema.rb", "db/migrate")
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: Rails.env.development?,
37
- auto_rollback_disabled: ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?,
38
- ui_enabled: Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?,
39
- git_hooks_enabled: ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?,
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: ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?,
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: ENV.fetch("ACTUAL_DB_SCHEMA_MIGRATIONS_STORAGE", "file").to_sym
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
- table_name = ActualDbSchema::Store::DbAdapter::TABLE_NAME
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
- if defined?(ActiveRecord::SchemaDumper) && ActiveRecord::SchemaDumper.respond_to?(:ignore_tables)
21
- ActiveRecord::SchemaDumper.ignore_tables |= [table_name]
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
- next unless defined?(ActiveRecord::Tasks::DatabaseTasks)
25
- next unless ActiveRecord::Tasks::DatabaseTasks.respond_to?(:structure_dump_flags)
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 = ActualDbSchema.db_config[: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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActualDbSchema
4
+ module Instrumentation
5
+ ROLLBACK_EVENT = "rollback.actual_db_schema"
6
+ end
7
+ end
@@ -20,11 +20,28 @@ module ActualDbSchema
20
20
  end
21
21
 
22
22
  def configs
23
- # Rails < 6.0 has a Hash in configurations
24
- if ActiveRecord::Base.configurations.is_a?(Hash)
25
- [ActiveRecord::Base.configurations[ActiveRecord::Tasks::DatabaseTasks.env]]
26
- else
27
- ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env)
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
- migrate(migration, rolled_back_migrations, schema_name) if !manual_mode || user_wants_rollback?
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: #{branch_for(migration.version.to_s)})"
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 ||= SchemaParser.parse_string(old_schema_content.to_s)
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 ||= SchemaParser.parse_string(new_schema_content.to_s)
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
- current_table = ct[1]
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
- CHANGE_PATTERNS.each do |regex, kind|
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
- return "<pre>#{ERB::Util.html_escape(new_schema_content)}</pre>" if diff_output.strip.empty?
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 line.start_with?("---") || line.start_with?("+++") || line.match(/^@@/)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActualDbSchema
4
- VERSION = "0.9.0"
4
+ VERSION = "0.9.1"
5
5
  end
@@ -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
- schema_path = args[:schema_path] || "./db/schema.rb"
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.0
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-01-27 00:00:00.000000000 Z
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