nulldb/nulldb

View on GitHub
lib/active_record/connection_adapters/nulldb_adapter/core.rb

Summary

Maintainability
C
1 day
Test Coverage
class ActiveRecord::ConnectionAdapters::NullDBAdapter < ActiveRecord::ConnectionAdapters::AbstractAdapter

  # A convenience method for integratinginto RSpec.  See README for example of
  # use.
  def self.insinuate_into_spec(config)
    config.before :all do
      ActiveRecord::Base.establish_connection(:adapter => :nulldb)
    end

    config.after :all do
      ActiveRecord::Base.establish_connection(:test)
    end
  end

  # Recognized options:
  #
  # [+:schema+] path to the schema file, relative to Rails.root
  # [+:table_definition_class_name+] table definition class
  # (e.g. ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition for Postgres) or nil.
  def initialize(config={})
    @log            = StringIO.new
    @logger         = Logger.new(@log)
    @last_unique_id = 0
    @tables         = {'schema_info' => new_table_definition(nil)}
    @indexes        = Hash.new { |hash, key| hash[key] = [] }
    @schema_path    = config.fetch(:schema){ "db/schema.rb" }
    @config         = config.merge(:adapter => :nulldb)
    super *initialize_args
    @visitor ||= Arel::Visitors::ToSql.new self if defined?(Arel::Visitors::ToSql)

    if config[:table_definition_class_name]
      ActiveRecord::ConnectionAdapters::NullDBAdapter.send(:remove_const, 'TableDefinition')
      ActiveRecord::ConnectionAdapters::NullDBAdapter.const_set('TableDefinition',
        self.class.const_get(config[:table_definition_class_name]))
    end

    register_types
  end

  # A log of every statement that has been "executed" by this connection adapter
  # instance.
  def execution_log
    (@execution_log ||= [])
  end

  # A log of every statement that has been "executed" since the last time
  # #checkpoint! was called, or since the connection was created.
  def execution_log_since_checkpoint
    checkpoint_index = @execution_log.rindex(Checkpoint.new)
    checkpoint_index = checkpoint_index ? checkpoint_index + 1 : 0
    @execution_log[(checkpoint_index..-1)]
  end

  # Inserts a checkpoint in the log.  See also #execution_log_since_checkpoint.
  def checkpoint!
    self.execution_log << Checkpoint.new
  end

  def adapter_name
    "NullDB"
  end

  def supports_migrations?
    true
  end

  def create_table(table_name, options = {})
    table_definition = new_table_definition(self, table_name, options.delete(:temporary), options)

    unless options[:id] == false
      table_definition.primary_key(options[:primary_key] || "id")
    end

    yield table_definition if block_given?

    @tables[table_name.to_s] = table_definition
  end

  def rename_table(table_name, new_name)
    table_definition = @tables.delete(table_name.to_s)

    table_definition.name = new_name.to_s
    @tables[new_name.to_s] = table_definition
  end

  def add_index(table_name, column_names, **options)
    options[:unique] = false unless options.key?(:unique)
    column_names = Array.wrap(column_names).map(&:to_s)

    index, index_type, ignore = add_index_options(table_name, column_names, **options)

    if index.is_a?(ActiveRecord::ConnectionAdapters::IndexDefinition)
      @indexes[table_name] << index
    else
      # Rails < 6.1
      @indexes[table_name] << IndexDefinition.new(table_name, index, (index_type == 'UNIQUE'), column_names, [], [])
    end
  end

  # Rails 6.1+
  if ActiveRecord::VERSION::MAJOR >= 7 || (ActiveRecord::VERSION::MAJOR >= 6 and ActiveRecord::VERSION::MINOR > 0)
    def remove_index(table_name, column_name = nil, **options )
      index_name = index_name_for_remove(table_name, column_name, options)
      index = @indexes[table_name].reject! { |index| index.name == index_name }
    end
  else
    def remove_index(table_name,  options = {} )
      index_name = index_name_for_remove(table_name, options)
      index = @indexes[table_name].reject! { |index| index.name == index_name }
    end
  end

  def add_fk_constraint(*args)
    # NOOP
  end

  def add_pk_constraint(*args)
    # NOOP
  end

  def enable_extension(*)
    # NOOP
  end

  # Retrieve the table names defined by the schema
  def tables
    @tables.keys.map(&:to_s)
  end

  def views
    [] # TODO: Implement properly if needed - This is new method in rails
  end

  # Retrieve table columns as defined by the schema
  def columns(table_name, name = nil)
    if @tables.size <= 1
      ActiveRecord::Migration.verbose = false
      schema_path = if Pathname(@schema_path).absolute?
                      @schema_path
                    else
                      File.join(NullDB.configuration.project_root, @schema_path)
                    end
      Kernel.load(schema_path)
    end

    if table = @tables[table_name]
      table.columns.map do |col_def|
        col_args = default_column_arguments(col_def)
        ActiveRecord::ConnectionAdapters::NullDBAdapter::Column.new(*col_args)
      end
    else
      []
    end
  end

  # Retrieve table indexes as defined by the schema
  def indexes(table_name, name = nil)
    @indexes[table_name]
  end

  def execute(statement, name = nil)
    self.execution_log << Statement.new(entry_point, statement)
    NullObject.new
  end

  def exec_query(statement, name = 'SQL', binds = [], options = {})
    internal_exec_query(statement, name, binds, **options)
  end

  def internal_exec_query(statement, name = 'SQL', binds = [], prepare: false, async: false)
    self.execution_log << Statement.new(entry_point, statement)
    EmptyResult.new
  end

  def select_rows(statement, name = nil, binds = [], async: false)
    [].tap do
      self.execution_log << Statement.new(entry_point, statement)
    end
  end

  def insert(statement, name = nil, primary_key = nil, object_id = nil, sequence_name = nil, binds = [], returning: nil)
    with_entry_point(:insert) do
      super(statement, name, primary_key, object_id, sequence_name)
    end

    result = object_id || next_unique_id

    returning ? [result] : result
  end
  alias :create :insert

  def update(statement, name=nil, binds = [])
    with_entry_point(:update) do
      super(statement, name)
    end
  end

  def delete(statement, name=nil, binds = [])
    with_entry_point(:delete) do
      super(statement, name).size
    end
  end

  def select_all(statement, name=nil, binds = [], options = {})
    with_entry_point(:select_all) do
      super(statement, name)
    end
  end

  def select_one(statement, name=nil, binds = [])
    with_entry_point(:select_one) do
      super(statement, name)
    end
  end

  def select_value(statement, name=nil, binds = [])
    with_entry_point(:select_value) do
      super(statement, name)
    end
  end

  def select_values(statement, name=nil)
    with_entry_point(:select_values) do
      super(statement, name)
    end
  end

  def primary_key(table_name)
    columns(table_name).detect { |col| col.type == :primary_key }.try(:name)
  end

  def add_column(table_name, column_name, type, **options)
    super

    table_meta = @tables[table_name.to_s]
    return unless table_meta

    table_meta.column column_name, type, **options
  end

  def change_column(table_name, column_name, type, options = {})
    table_meta = @tables[table_name.to_s]
    column = table_meta.columns.find { |column| column.name == column_name.to_s }
    return unless column

    column.type = type
    column.options = options if options
  end

  def rename_column(table_name, column_name, new_column_name)
    table_meta = @tables[table_name.to_s]
    column = table_meta.columns.find { |column| column.name == column_name.to_s }
    return unless column

    column.name = new_column_name
  end

  def change_column_default(table_name, column_name, default_or_changes)
    table_meta = @tables[table_name.to_s]
    column = table_meta.columns.find { |column| column.name == column_name.to_s }

    return unless column

    if default_or_changes.kind_of? Hash
      column.default = default_or_changes[:to]
    else
      column.default = default_or_changes
    end
  end

  protected

  def select(statement, name = nil, binds = [], prepare: nil, async: nil)
    EmptyResult.new.tap do |r|
      r.bind_column_meta(columns_for(name))
      self.execution_log << Statement.new(entry_point, statement)
    end
  end

  private

  def columns_for(table_name)
    table_meta = @tables[table_name]
    return [] unless table_meta
    table_meta.columns
  end

  def next_unique_id
    @last_unique_id += 1
  end

  def with_entry_point(method)
    if entry_point.nil?
      with_thread_local_variable(:entry_point, method) do
        yield
      end
    else
      yield
    end
  end

  def entry_point
    Thread.current[:entry_point]
  end

  def with_thread_local_variable(name, value)
    old_value = Thread.current[name]
    Thread.current[name] = value
    begin
      yield
    ensure
      Thread.current[name] = old_value
    end
  end

  def includes_column?
    false
  end

  def new_table_definition(adapter = nil, table_name = nil, is_temporary = nil, options = {})
    case ::ActiveRecord::VERSION::MAJOR
    when 6, 7
      TableDefinition.new(self, table_name, temporary: is_temporary, options: options.except(:id))
    when 5
      TableDefinition.new(table_name, is_temporary, options.except(:id), nil)
    else
      raise "Unsupported ActiveRecord version #{::ActiveRecord::VERSION::STRING}"
    end
  end

  def default_column_arguments(col_def)
    [
      col_def.name.to_s,
      col_def.default.present? ? col_def.default.to_s : nil,
      sql_type_definition(col_def),
      col_def.null.nil? || col_def.null
    ]
  end

  def sql_type_definition(col_def)
    ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
      type: col_def.type,
      sql_type: col_def.type.to_s,
      limit: col_def.limit
    )
  end

  def initialize_args
    [nil, @logger, @config]
  end

  # Register types only once to avoid ActiveRecord::TypeConflictError
  # in ActiveRecord::Type::Registration#<=>
  REGISTRATION_MUTEX = Mutex.new

  def register_types
    REGISTRATION_MUTEX.synchronize do
      return if self.class.types_registered

      self.class.types_registered = true
    end

    ActiveRecord::Type.register(
      :primary_key,
      ActiveModel::Type::Integer,
      adapter: adapter_name,
      override: true
    )

    ActiveRecord::Type.add_modifier({ array: true }, DummyOID, adapter: :nulldb)
    ActiveRecord::Type.add_modifier({ range: true }, DummyOID, adapter: :nulldb)
  end

  class << self
    attr_accessor :types_registered
  end

  class DummyOID < ActiveModel::Type::Value
    attr_reader :subtype

    def initialize(*args)
      @subtype = args.first
    end
  end
end