module HairTrigger

Constants

MYSQL_ADAPTERS
POSTGRESQL_ADAPTERS
SQLITE_ADAPTERS
VERSION

Attributes

migration_path[W]
model_path[W]
pg_schema[W]
schema_rb_path[W]

Public Class Methods

adapter_name_for(adapter) click to toggle source
# File lib/hair_trigger.rb, line 238
def adapter_name_for(adapter)
  adapter.adapter_name.downcase.sub(/\d$/, '').to_sym
end
current_migrations(options = {}) click to toggle source
# File lib/hair_trigger.rb, line 61
def current_migrations(options = {})
  if options[:in_rake_task]
    options[:include_manual_triggers] = true
    options[:schema_rb_first] = true
    options[:skip_pending_migrations] = true
  end

  # if we're in a db:schema:dump task (explicit or kicked off by db:migrate),
  # we evaluate the previous schema.rb (if it exists), and then all applied
  # migrations in order (even ones older than schema.rb). this ensures we
  # handle db:migrate:down scenarios correctly
  #
  # if we're not in such a rake task (i.e. we just want to know what
  # triggers are defined, whether or not they are applied in the db), we
  # evaluate all migrations along with schema.rb, ordered by version
  migrator = self.migrator
  migrated = migrator.migrated rescue []
  migrations = []
  migrator.migrations.each do |migration|
    next if options[:skip_pending_migrations] && !migrated.include?(migration.version)
    triggers = MigrationReader.get_triggers(migration, options)
    migrations << [migration, triggers] unless triggers.empty?
  end

  if previous_schema = (options.has_key?(:previous_schema) ? options[:previous_schema] : File.exist?(schema_rb_path) && File.read(schema_rb_path))
    base_triggers = MigrationReader.get_triggers(previous_schema, options)
    unless base_triggers.empty?
      version = (previous_schema =~ /ActiveRecord::Schema(\[\d\.\d\])?\.define\(version\: (.*)\)/) && $2.to_i
      migrations.unshift [OpenStruct.new({:version => version}), base_triggers]
    end
  end

  migrations = migrations.sort_by{|(migration, triggers)| migration.version} unless options[:schema_rb_first]

  all_builders = []
  migrations.each do |(migration, triggers)|
    triggers.each do |new_trigger|
      # if there is already a trigger with this name, delete it since we are
      # either dropping it or replacing it
      new_trigger.prepare!
      all_builders.delete_if{ |(n, t)| t.prepared_name == new_trigger.prepared_name }
      all_builders << [migration.name, new_trigger] unless new_trigger.options[:drop]
    end
  end

  all_builders
end
current_triggers() click to toggle source
# File lib/hair_trigger.rb, line 21
def current_triggers
  # see what the models say there should be
  canonical_triggers = models.map(&:triggers).flatten.compact
  canonical_triggers.each(&:prepare!) # interpolates any vars so we match the migrations
end
generate_migration(silent = false) click to toggle source
# File lib/hair_trigger.rb, line 113
    def generate_migration(silent = false)
      begin
        canonical_triggers = current_triggers
      rescue
        $stderr.puts $!
        exit 1
      end

      migrations = current_migrations
      migration_names = migrations.map(&:first)
      existing_triggers = migrations.map(&:last)

      up_drop_triggers = []
      up_create_triggers = []
      down_drop_triggers = []
      down_create_triggers = []

      # see which triggers need to be dropped
      existing_triggers.each do |existing|
        next if canonical_triggers.any?{ |t| t.prepared_name == existing.prepared_name }
        up_drop_triggers.concat existing.drop_triggers
        down_create_triggers << existing
      end

      # see which triggers need to be added/replaced
      (canonical_triggers - existing_triggers).each do |new_trigger|
        up_create_triggers << new_trigger
        down_drop_triggers.concat new_trigger.drop_triggers
        if existing = existing_triggers.detect{ |t| t.prepared_name == new_trigger.prepared_name }
          # it's not sufficient to rely on the new trigger to replace the old
          # one, since we could be dealing with trigger groups and the name
          # alone isn't sufficient to know which component triggers to remove
          up_drop_triggers.concat existing.drop_triggers
          down_create_triggers << existing
        end
      end

      return if up_drop_triggers.empty? && up_create_triggers.empty?

      migration_name = infer_migration_name(migration_names, up_create_triggers, up_drop_triggers)
      migration_version = infer_migration_version(migration_name)
      file_name = migration_path + '/' + migration_version + "_" + migration_name.underscore + ".rb"
      FileUtils.mkdir_p migration_path
      File.open(file_name, "w") { |f| f.write <<-RUBY }
# This migration was auto-generated via `rake db:generate_trigger_migration'.
# While you can edit this file, any changes you make to the definitions here
# will be undone by the next auto-generated trigger migration.

class #{migration_name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]
  def up
    #{(up_drop_triggers + up_create_triggers).map{ |t| t.to_ruby('    ') }.join("\n\n").lstrip}
  end

  def down
    #{(down_drop_triggers + down_create_triggers).map{ |t| t.to_ruby('    ') }.join("\n\n").lstrip}
  end
end
      RUBY
      file_name
    end
infer_migration_name(migration_names, create_triggers, drop_triggers) click to toggle source
# File lib/hair_trigger.rb, line 174
def infer_migration_name(migration_names, create_triggers, drop_triggers)
  if create_triggers.size > 0
    migration_base_name = "create trigger#{create_triggers.size > 1 ? 's' : ''} "
    name_parts = create_triggers.map { |t| [t.options[:table], t.options[:events].join(" ")].join(" ") }.uniq
    part_limit = 4
  else
    migration_base_name = "drop trigger#{drop_triggers.size > 1 ? 's' : ''} "
    name_parts = drop_triggers.map { |t| t.options[:table] }
    part_limit = 6
  end

  # don't let migration names get too ridiculous
  if name_parts.size > part_limit
    migration_base_name << " multiple tables"
  else
    migration_base_name << name_parts.join(" OR ")
  end

  migration_base_name = migration_base_name.
    downcase.
    gsub(/[^a-z0-9_]/, '_').
    gsub(/_+/, '_').
    camelize

  name_version = nil
  while migration_names.include?("#{migration_base_name}#{name_version}")
    name_version = name_version.to_i + 1
  end

  "#{migration_base_name}#{name_version}"
end
infer_migration_version(migration_name) click to toggle source
# File lib/hair_trigger.rb, line 214
def infer_migration_version(migration_name)
  timestamped_migrations ?
    Time.now.getutc.strftime("%Y%m%d%H%M%S") :
    Dir.glob(migration_path + '/*rb').
      map{ |f| f.gsub(/.*\/(\d+)_.*/, '\1').to_i}.
      inject(0){ |curr, i| i > curr ? i : curr } + 1
end
migration_path() click to toggle source
# File lib/hair_trigger.rb, line 230
def migration_path
  @migration_path ||= 'db/migrate'
end
migrations_current?() click to toggle source
# File lib/hair_trigger.rb, line 109
def migrations_current?
  current_migrations.map(&:last).sort.eql? current_triggers.sort
end
migrator() click to toggle source
# File lib/hair_trigger.rb, line 44
def migrator
  if Gem::Version.new("7.2.0") <= ActiveRecord.gem_version
    connection = ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool
    schema_migration = connection.schema_migration
    migrations = ActiveRecord::MigrationContext.new(migration_path, schema_migration).migrations
    ActiveRecord::Migrator.new(:up, migrations, schema_migration, ActiveRecord::InternalMetadata.new(connection))
  elsif Gem::Version.new("7.1.0") <= ActiveRecord.gem_version
    connection = ActiveRecord::Tasks::DatabaseTasks.migration_connection
    schema_migration = connection.schema_migration
    migrations = ActiveRecord::MigrationContext.new(migration_path, schema_migration).migrations
    ActiveRecord::Migrator.new(:up, migrations, schema_migration, ActiveRecord::InternalMetadata.new(connection))
  else
    migrations = ActiveRecord::MigrationContext.new(migration_path, ActiveRecord::SchemaMigration).migrations
    ActiveRecord::Migrator.new(:up, migrations, ActiveRecord::SchemaMigration)
  end
end
model_path() click to toggle source
# File lib/hair_trigger.rb, line 222
def model_path
  @model_path ||= 'app/models'
end
models() click to toggle source
# File lib/hair_trigger.rb, line 27
def models
  if defined?(Rails)
    Rails.application.eager_load!
  else
    Dir[model_path + '/*rb'].each do |model|
      class_name = model.sub(/\A.*\/(.*?)\.rb\z/, '\1').camelize
      next unless File.read(model) =~ /^\s*trigger[\.\(]/
      begin
        require "./#{model}" unless Object.const_defined?(class_name)
      rescue StandardError, LoadError
        raise "unable to load #{class_name} and its trigger(s)"
      end
    end
  end
  ActiveRecord::Base.descendants
end
pg_schema() click to toggle source
# File lib/hair_trigger.rb, line 234
def pg_schema
  @pg_schema ||= 'public'
end
schema_rb_path() click to toggle source
# File lib/hair_trigger.rb, line 226
def schema_rb_path
  @schema_rb_path ||= 'db/schema.rb'
end
timestamped_migrations() click to toggle source
# File lib/hair_trigger.rb, line 206
def timestamped_migrations
  if ActiveRecord::VERSION::STRING >= "7.0."
    ActiveRecord.timestamped_migrations
  else
    ActiveRecord::Base.timestamped_migrations
  end
end