Module: YiffSpace::FixTracker

Defined in:
lib/yiffspace/fix_tracker.rb

Overview

Tracks which db/fixes/.rb scripts a host app has applied, in a fixes table (see db/migrate/_create_fixes.rb) the same way ActiveRecord's own schema_migrations table tracks migrations. Used by the fixes:* rake tasks (lib/tasks/fixes.rake).

Defined Under Namespace

Classes: MigrationRequired

Constant Summary collapse

REQUIRES_MIGRATION_PATTERN =

A fix script calls this as its first statement to declare that a migration must already be applied before it can run, e.g.:

YiffSpace::FixTracker.requires_migration!("20260822004454")

Written as a literal string call like that (not built dynamically) so YiffSpace::MigrationSync can also read it back out of the file with #required_migration_for, without loading/running the fix, to order fixes:migrate_all correctly.

/^\s*YiffSpace::FixTracker\.requires_migration!\(\s*["']([^"']+)["']\s*\)/

Class Method Summary collapse

Class Method Details

.all_fixes ⇒ Object

Only numbered one-time fixes are tracked/auto-applied - a host app's db/fixes may also hold reusable on-demand maintenance scripts that aren't meant to run automatically as part of fixes:migrate. Those stay reachable via fixes:run[name].



80
81
82
# File 'lib/yiffspace/fix_tracker.rb', line 80

def all_fixes
  Rails.root.glob("db/fixes/*.rb").map { |path| File.basename(path, ".rb") }.grep(/\A\d/).sort_by { |name| sort_key(name) }
end

.applied ⇒ Object



84
85
86
# File 'lib/yiffspace/fix_tracker.rb', line 84

def applied
  ActiveRecord::Base.connection.select_rows('SELECT id, "index" FROM fixes').to_set
end

.applied?(name) ⇒ Boolean

Returns:

  • (Boolean)


88
89
90
91
92
93
# File 'lib/yiffspace/fix_tracker.rb', line 88

def applied?(name)
  id, index = key_for(name)
  conn = ActiveRecord::Base.connection
  index_clause = index.nil? ? '"index" IS NULL' : %("index" = #{index})
  conn.select_value("SELECT 1 FROM fixes WHERE id = #{id} AND #{index_clause}").present?
end

.dump_structure_sql! ⇒ Object

Mirrors how ActiveRecord appends INSERT INTO "schema_migrations" to structure.sql after a schema dump (see Connection#dump_schema_versions) - appends the currently-applied fixes the same way, so loading structure.sql into a fresh database marks them applied too instead of leaving fixes:migrate to re-run every historical fix. Hooked into db:schema:dump in lib/tasks/fixes.rake, so it runs on every schema dump, not just fixes:migrate.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/yiffspace/fix_tracker.rb', line 121

def dump_structure_sql!
  db_config = ActiveRecord::Base.connection_db_config
  return unless db_config.schema_format == :sql

  filename = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config)
  return unless filename && File.exist?(filename)

  conn = ActiveRecord::Base.connection
  return unless conn.table_exists?(:fixes)

  rows = conn.select_rows('SELECT id, "index" FROM fixes ORDER BY id DESC, "index" DESC NULLS LAST')
  return if rows.empty?

  values = rows.map { |id, index| "(#{id}, #{index || 'NULL'})" }.join(",\n")
  File.open(filename, "a") do |f|
    f.puts("      INSERT INTO \"fixes\" (id, \"index\") VALUES\n      \#{values};\n    TEXT\n  end\nend\n")

.fix_path(name) ⇒ Object



23
24
25
# File 'lib/yiffspace/fix_tracker.rb', line 23

def fix_path(name)
  Rails.root.glob("db/fixes/#{name}.rb").first
end

.key_for(name) ⇒ Object

db/fixes filenames are "_description.rb", occasionally "__description.rb" (e.g. 1_1_..., 1_2_...) for a handful of fixes split into ordered steps - the fixes table mirrors this as an (id, index) pair, with index null for fixes with no subtype.



65
66
67
68
69
# File 'lib/yiffspace/fix_tracker.rb', line 65

def key_for(name)
  id, maybe_index = name.split("_", 3)
  index = maybe_index =~ /\A\d+\z/ ? maybe_index.to_i : nil
  [id.to_i, index]
end

.migration_applied?(version) ⇒ Boolean

Returns:

  • (Boolean)


33
34
35
# File 'lib/yiffspace/fix_tracker.rb', line 33

def migration_applied?(version)
  migration_context.get_all_versions.include?(version.to_i)
end

.migration_context ⇒ Object



58
59
60
# File 'lib/yiffspace/fix_tracker.rb', line 58

def migration_context
  ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool.migration_context
end

.pending ⇒ Object



95
96
97
98
# File 'lib/yiffspace/fix_tracker.rb', line 95

def pending
  applied_keys = applied
  all_fixes.reject { |name| applied_keys.include?(key_for(name)) }
end

.record!(name) ⇒ Object



100
101
102
103
104
105
106
107
# File 'lib/yiffspace/fix_tracker.rb', line 100

def record!(name)
  id, index = key_for(name)
  conn = ActiveRecord::Base.connection
  conn.execute("    INSERT INTO fixes (id, \"index\") VALUES (\#{id}, \#{index || 'NULL'})\n    ON CONFLICT (id, \"index\") DO NOTHING\n  SQL\nend\n".squish)

.required_migration_for(name) ⇒ Object

Statically reads a fix script's declared requires_migration! version, if any, without loading/running it - nil if the fix has no such call.



39
40
41
42
43
44
# File 'lib/yiffspace/fix_tracker.rb', line 39

def required_migration_for(name)
  path = fix_path(name)
  return nil unless path

  path.read.match(REQUIRES_MIGRATION_PATTERN)&.captures&.first
end

.requires_migration!(version) ⇒ Object

Raises:



27
28
29
30
31
# File 'lib/yiffspace/fix_tracker.rb', line 27

def requires_migration!(version)
  return if migration_applied?(version)

  raise(MigrationRequired, "migration #{version} must be applied before this fix can run")
end

.resolve(id_or_name) ⇒ Object

Resolves a YiffSpace::Fixers::RequiresFix id_or_name to the fix name(s) it refers to - an Integer id covers every step sharing that id (e.g. 2 -> %w[2_1_first_step 2_2_second_step]), a name matches that one fix file exactly.



49
50
51
52
53
54
55
56
# File 'lib/yiffspace/fix_tracker.rb', line 49

def resolve(id_or_name)
  if id_or_name.is_a?(Integer)
    all_fixes.select { |name| key_for(name).first == id_or_name }
  else
    name = id_or_name.to_s.delete_suffix(".rb")
    all_fixes.select { |candidate| candidate == name }
  end
end

.run!(path) ⇒ Object



109
110
111
112
113
114
# File 'lib/yiffspace/fix_tracker.rb', line 109

def run!(path)
  name = File.basename(path, ".rb")
  load(File.expand_path(path))
  record!(name)
  name
end

.sort_key(name) ⇒ Object

Sort on the (id, index) parts, not the filename string, so 12 doesn't sort before 2_2.



72
73
74
75
# File 'lib/yiffspace/fix_tracker.rb', line 72

def sort_key(name)
  id, index = key_for(name)
  [id, index || 0]
end