Review Apps Database Migration Issues with Ruby and heroku_ext schema

Issue

Some customers have seen difficulties with database migrations for Review Apps in Ruby due to compatibility issues between how Rails renders schemas and how new Heroku databases are provisioned using the heroku_ext schema.

Resolution

As this issue specifically relates to how Rails expects database schemas to be created, it can be resolved by creating a monkey patch similar to the following which will ensure schema's get rendered to be compatible with the heroku_ext schema. In short, you need to extend core Rails classes to enable you to specify schemas other than what Rails expects by default.

The following is an example of a possible way to do this (we tested with Rails 7). You will need to make modifications for other versions of Rails.

Notes:
1. THIS CODE IS FOR EXAMPLE PURPOSES ONLY. Any changes to your application code based off this pattern should be modified and tested to suit your individual applications use case.
2. This example uses structure.sql which we recommend using, but the same pattern will work for schema.rb if you make changes as applicable.


Setting config to use structure.sql

You can set your apps config to use structure.sql in application.rb:

module YourApp
  class Application < Rails::Application
    config.load_defaults 6.0
 
    # Add this line:
    config.active_record.schema_format = :sql
  end
end

Create the patch files

  • Note: we have created 2 files in this example, but you could combine these files into one if you preferred

config/initializers/monkey_patches.rb

# frozen_string_literal: true

Dir[Rails.root.join("lib", "core_extensions", "*.rb")].sort.each { |f| require f }

ActiveSupport.on_load(:active_record) do
  raise "Review code_extensions and monkey_patches.rb" unless Rails.version == "INSERT_YOUR_RAILS_VERSION_HERE"

  ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(
    CoreExtensions::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
  )
  ActiveRecord::Tasks::PostgreSQLDatabaseTasks.prepend(
    CoreExtensions::ActiveRecord::Tasks::PostgreSQLDatabaseTasks
  )
  ActiveRecord::SchemaDumper.prepend(
    CoreExtensions::ActiveRecord::SchemaDumper
  )
  ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.prepend(
    CoreExtensions::ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper
  )
end

lib/core_extensions/heroku_ext.rb

# frozen_string_literal: true

module CoreExtensions
  module ActiveRecord
    module SchemaDumper
      def dump(stream)
        header(stream)
        schemas(stream)
        extensions(stream)
        types(stream)
        tables(stream)
        trailer(stream)
        stream
      end
    end

    module ConnectionAdapters
      module PostgreSQL
        module SchemaDumper
          def schemas(stream)
            schema_names = @connection.schema_names.reject { |n| n.in?(%w[public]) }
            schema_names.each do |schema|
              stream.puts "  create_schema '#{schema}', if_not_exists: true"
            end
            stream.puts
          end

          def extensions(stream)
            extensions = @connection.extensions_with_schemas
            if extensions.any?
              stream.puts "  # These are extensions that must be enabled in order to support this database"
              extensions.keys.sort.each do |extname|
                schema = extensions[extname]
                stream.puts "  enable_extension #{extname}, schema: '#{schema}'"
              end
              stream.puts
            end
          end
        end
      end

      module PostgreSQLAdapter
        def create_schema(schema_name, **options)
          execute "CREATE SCHEMA#{" IF NOT EXISTS" if options[:if_not_exists]} #{quote_schema_name(schema_name)}"
        end

        def enable_extension(name, **options)
          exec_query("CREATE EXTENSION IF NOT EXISTS \"#{name}\"#{" WITH SCHEMA #{options[:schema]}" if options[:schema]}").tap {
            reload_type_map
          }
        end

        def extensions_with_schemas
          exec_query(
            [
              "SELECT e.extname as extname, n.nspname as nspname",
              "FROM pg_extension e",
              "LEFT JOIN pg_catalog.pg_namespace n",
              "ON n.oid = e.extnamespace",
            ].join(" "),
            "SCHEMA"
          ).cast_values.to_h
        end
      end
    end

    module Tasks
      module PostgreSQLDatabaseTasks
        def structure_dump(filename, extra_flags)
          super
          structure = File.read(filename)
          new_structure = heroku_ext_if_not_exists(structure)
          File.open(filename, "w") { |file| file.puts new_structure }
        end

        def heroku_ext_if_not_exists(structure)
          structure.gsub(/CREATE SCHEMA heroku_ext;/, "CREATE SCHEMA IF NOT EXISTS heroku_ext;")
        end

        def dequalify_gen_random_uuid(structure)
          structure.gsub(/public\.gen_random_uuid\(/, "gen_random_uuid(")
        end
      end
    end
  end
end

Update any pre-existing migrations files similar to below.

  • Note: you only need to update the migrations files and structure.sql once. Once the patch has deployed you do not need to manually update files again:

structure.sql needs to follow the CREATE EXTENSION IF NOT EXISTS xxx; format so, e.g:

CREATE SCHEMA IF NOT EXISTS heroku_ext;

CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA heroku_ext

db/migrate/20210722173124_create_resources.rb

class CreateResources < ActiveRecord::Migration[6.0]
  def change
    create_schema 'heroku_ext', if_not_exists: true
    enable_extension 'pgcrypto', schema: 'heroku_ext'

Ask on Stack Overflow

Engage with a community of passionate experts to get the answers you need

Ask on Stack Overflow

Heroku Support

Create a support ticket and our support experts will get back to you

Contact Heroku Support