migrate.rb 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. # lib/migrate.rb
  2. # Lightweight sequential schema migration runner.
  3. #
  4. # Tracks applied migrations in a `schema_migrations` table (single source of
  5. # truth). Every migration is idempotent — it uses ADD COLUMN IF NOT EXISTS and
  6. # CREATE TABLE IF NOT EXISTS so re-running a partially-applied version is safe.
  7. #
  8. # Usage — call once at container/process startup, before any scrapers run:
  9. #
  10. # require_relative "lib/migrate"
  11. # Migrate.run!
  12. #
  13. # Or run as a standalone script:
  14. #
  15. # ruby /app/lib/migrate.rb
  16. require_relative "./db"
  17. module Migrate
  18. # -------------------------------------------------------------------------
  19. # Migration definitions — add new ones at the END of this array only.
  20. # Never edit or reorder existing entries; create a new version instead.
  21. # -------------------------------------------------------------------------
  22. MIGRATIONS = [
  23. {
  24. version: 1,
  25. description: "Add enrichment and geocode columns to all existing da_* tables",
  26. up: -> {
  27. # These are the columns every da_* table should have. New tables already
  28. # get all of them via DB.ensure_table!; this migration adds them to tables
  29. # that were created before these columns were introduced.
  30. cols = {
  31. "on_notice_to" => "DATE NULL",
  32. "on_notice_to_raw" => "VARCHAR(80) NULL",
  33. "title_reference" => "TEXT NULL",
  34. "property_id" => "TEXT NULL",
  35. "area_sqm" => "DOUBLE NULL",
  36. "area_ha" => "DOUBLE NULL",
  37. "address_std" => "VARCHAR(255) NULL",
  38. "street" => "VARCHAR(120) NULL",
  39. "locality" => "VARCHAR(120) NULL",
  40. "state" => "VARCHAR(10) NULL",
  41. "postcode" => "VARCHAR(10) NULL",
  42. "document_url" => "TEXT NULL",
  43. "local_document_url" => "TEXT NULL",
  44. "lat" => "DECIMAL(10,7) NULL",
  45. "lng" => "DECIMAL(10,7) NULL"
  46. }
  47. Migrate.da_tables.each do |table|
  48. esc = DB.client.escape(table)
  49. cols.each do |col, defn|
  50. DB.client.query(
  51. "ALTER TABLE `#{esc}` ADD COLUMN IF NOT EXISTS `#{col}` #{defn}"
  52. )
  53. rescue Mysql2::Error => e
  54. warn "[migrate] skipped #{table}.#{col}: #{e.message}"
  55. end
  56. end
  57. }
  58. },
  59. {
  60. version: 2,
  61. description: "Create geo_cache table",
  62. up: -> {
  63. DB.client.query(<<~SQL)
  64. CREATE TABLE IF NOT EXISTS geo_cache (
  65. id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  66. q_hash CHAR(40) NOT NULL UNIQUE,
  67. query_text VARCHAR(255) NOT NULL,
  68. formatted VARCHAR(255),
  69. street VARCHAR(255),
  70. locality VARCHAR(120),
  71. state VARCHAR(10),
  72. postcode VARCHAR(10),
  73. lat DECIMAL(10,7),
  74. lng DECIMAL(10,7),
  75. raw_json MEDIUMTEXT,
  76. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  77. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  78. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  79. SQL
  80. }
  81. }
  82. ].freeze
  83. # -------------------------------------------------------------------------
  84. # Public API
  85. # -------------------------------------------------------------------------
  86. # Apply all pending migrations and record them in schema_migrations.
  87. # Raises on unexpected errors so the caller (run_all.sh) aborts early.
  88. def self.run!
  89. ensure_migrations_table!
  90. applied = applied_versions
  91. pending = MIGRATIONS.reject { |m| applied.include?(m[:version]) }
  92. if pending.empty?
  93. puts "[migrate] schema up to date (#{MIGRATIONS.size} migration(s) applied)"
  94. return
  95. end
  96. pending.each do |m|
  97. puts "[migrate] applying v#{m[:version]}: #{m[:description]}"
  98. m[:up].call
  99. stmt = DB.client.prepare(
  100. "INSERT INTO schema_migrations (version, description) VALUES (?, ?)"
  101. )
  102. stmt.execute(m[:version], m[:description])
  103. puts "[migrate] v#{m[:version]} done"
  104. end
  105. end
  106. # -------------------------------------------------------------------------
  107. # Helpers (private to module)
  108. # -------------------------------------------------------------------------
  109. def self.ensure_migrations_table!
  110. DB.client.query(<<~SQL)
  111. CREATE TABLE IF NOT EXISTS schema_migrations (
  112. version INT UNSIGNED NOT NULL,
  113. description VARCHAR(255) NOT NULL,
  114. applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  115. PRIMARY KEY (version)
  116. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  117. SQL
  118. end
  119. def self.applied_versions
  120. rs = DB.client.query("SELECT version FROM schema_migrations ORDER BY version")
  121. rs.map { |r| r["version"] }.to_set
  122. end
  123. # All da_* tables currently in the database.
  124. def self.da_tables
  125. # The backslash escapes _ (a LIKE wildcard) so only genuine da_ prefixes match.
  126. rs = DB.client.query("SHOW TABLES LIKE 'da\\_%'")
  127. rs.map { |r| r.values.first }
  128. end
  129. private_class_method :ensure_migrations_table!, :applied_versions, :da_tables
  130. end
  131. # Allow running as a standalone script: ruby /app/lib/migrate.rb
  132. if __FILE__ == $0
  133. Migrate.run!
  134. end