module ModelSchema::Plugin::ClassMethods

Public Instance Methods

model_schema(options={}, &block) click to toggle source

Checks if the model’s table schema matches the schema specified by the given block. Raises a SchemaError if this isn’t the case.

options: :disable => true to disable schema checks;

you may also set the ENV variable DISABLE_MODEL_SCHEMA=1

:no_indexes => true to disable index checks

# File lib/model_schema/plugin.rb, line 15
def model_schema(options={}, &block)
  return if ENV[DISABLE_MODEL_SCHEMA_KEY] == '1' || options[:disable]
  db.extension(:schema_dumper)

  # table generators are Sequel's way of representing schemas
  db_generator = table_generator
  exp_generator = db.create_table_generator(&block)
  
  schema_errors = check_all(FIELD_COLUMNS, db_generator, exp_generator)
  if !options[:no_indexes]
    schema_errors += check_all(FIELD_INDEXES, db_generator, exp_generator)
  end
  
  raise SchemaError.new(table_name, schema_errors) if schema_errors.length > 0
end

Private Instance Methods

check_all(field, db_generator, exp_generator) click to toggle source

Check if db_generator and exp_generator match for the given field (FIELD_COLUMNS for columns or FIELD_INDEXES for indexes).

# File lib/model_schema/plugin.rb, line 80
def check_all(field, db_generator, exp_generator)
  # To find an accurate diff, we perform two passes on exp_array. In the
  # first pass, we find perfect matches between exp_array and db_array,
  # deleting the corresponding elements. In the second pass, for each
  # exp_elem in exp_array, we find the closest db_elem in db_array that
  # matches it. We then add a mismatch diff between db_elem and exp_elem
  # and remove db_elem from db_array. If no db_elem is deemed close
  # enough, we add a missing diff for exp_elem. Finally, we add an extra
  # diff for each remaining db_elem in db_array.

  # don't modify original arrays
  db_array = db_generator.send(field).dup
  exp_array = exp_generator.send(field).dup

  # first pass: remove perfect matches
  exp_array.select! do |exp_elem|
    diffs = db_array.map do |db_elem|
      check_single(field, :db_generator => db_generator,
                          :exp_generator => exp_generator,
                          :db_elem => db_elem,
                          :exp_elem => exp_elem)
    end

    index = diffs.find_index(nil)
    if index
      # found perfect match; delete elem so it won't be matched again
      db_array.delete_at(index)
      false  # we've accounted for this element
    else
      true  # we still need to account for this element
    end
  end

  schema_diffs = []

  # second pass: find diffs
  exp_array.each do |exp_elem|
    index = find_close_match(field, exp_elem, db_array)

    if index
      # add mismatch diff between exp_elem and db_array[index]
      schema_diffs << check_single(field, :db_generator => db_generator,
                                          :exp_generator => exp_generator,
                                          :db_elem => db_array[index],
                                          :exp_elem => exp_elem)
      db_array.delete_at(index)
    else
      # add missing diff, since no db_elem is deemed close enough
      schema_diffs << {:field => field,
                       :type => SchemaError::TYPE_MISSING,
                       :generator => exp_generator,
                       :elem => exp_elem}
    end
  end

  # because we deleted as we went on, db_array holds extra elements
  db_array.each do |db_elem|
    schema_diffs << {:field => field,
                     :type => SchemaError::TYPE_EXTRA,
                     :generator => db_generator,
                     :elem => db_elem}
  end

  schema_diffs
end
check_single(field, opts) click to toggle source

Check if the given database element matches the expected element.

field: FIELD_COLUMNS for columns or FIELD_INDEXES for indexes opts:

:db_generator => db table generator
:exp_generator => expected table generator
:db_elem => column, constraint, or index from db_generator
:exp_elem => column, constraint, or index from exp_generator
# File lib/model_schema/plugin.rb, line 167
def check_single(field, opts)
  db_generator, exp_generator = opts.values_at(:db_generator, :exp_generator)
  db_elem, exp_elem = opts.values_at(:db_elem, :exp_elem)

  error = {:field => field,
           :type => SchemaError::TYPE_MISMATCH,
           :db_generator => db_generator,
           :exp_generator => exp_generator,
           :db_elem => db_elem,
           :exp_elem => exp_elem}

  # db_elem and exp_elem now have the same keys; compare then
  case field
  when FIELD_COLUMNS
    db_elem_defaults = DEFAULT_COL.merge(db_elem)
    exp_elem_defaults = DEFAULT_COL.merge(exp_elem)
    return error if db_elem_defaults.length != exp_elem_defaults.length

    type_literal = db.method(:type_literal)
    # already accounted for in type check
    keys_accounted_for = [:text, :fixed, :size, :serial]

    match = db_elem_defaults.all? do |key, value|
      if key == :type
        # types could either be strings or ruby types; normalize them
        db_type = type_literal.call(db_elem_defaults).to_s
        exp_type = type_literal.call(exp_elem_defaults).to_s
        db_type == exp_type
      elsif keys_accounted_for.include?(key)
        true
      else
        value == exp_elem_defaults[key]
      end
    end

  when FIELD_INDEXES
    db_elem_defaults = DEFAULT_INDEX.merge(db_elem)
    exp_elem_defaults = DEFAULT_INDEX.merge(exp_elem)
    return error if db_elem_defaults.length != exp_elem_defaults.length

    # if no index name is specified, accept any name
    db_elem_defaults.delete(:name) if !exp_elem_defaults[:name]
    match = db_elem_defaults.all? {|key, value| value == exp_elem_defaults[key]}
  end

  match ? nil : error
end
find_close_match(field, exp_elem, db_array) click to toggle source

Returns the index of an element in db_array that closely matches exp_elem, or nil if no such element exists.

# File lib/model_schema/plugin.rb, line 148
def find_close_match(field, exp_elem, db_array)
  case field
  when FIELD_COLUMNS
    db_array.find_index {|e| e[:name] == exp_elem[:name]}
  when FIELD_INDEXES
    db_array.find_index do |e|
      e[:name] == exp_elem[:name] || e[:columns] == exp_elem[:columns]
    end
  end
end
table_generator() click to toggle source

Returns the table generator representing this table.

# File lib/model_schema/plugin.rb, line 34
def table_generator
  begin
    db_generator_explicit = db.send(:dump_table_generator, table_name,
                                    :same_db => true)
    db_generator_generic = db.send(:dump_table_generator, table_name)
  rescue Sequel::DatabaseError => error
    if error.message.include?('PG::UndefinedTable:')
      fail NameError, "Table #{table_name} doesn't exist."
    end
  end

  # db_generator_explicit contains explicit string types for each field,
  # specific to the current database; db_generator_generic contains ruby
  # types for each field. When there's no corresponding ruby type,
  # db_generator_generic defaults to the String type. We'd like to
  # combine db_generator_explicit and db_generator_generic into one
  # generator, where ruby types are used if they are accurate. If there
  # is no accurate ruby type, we use the explicit database type. This
  # gives us cleaner column dumps, as ruby types have a better, more
  # generic interface (e.g. `String :col_name` as opposed to
  # `column :col_name, 'varchar(255)`).
  
  # start with db_generator_generic, and correct as need be
  db_generator = db_generator_generic.dup

  # avoid using Sequel::Model.db_schema because it has odd caching
  # behavior across classes that breaks tests
  db.schema(table_name).each do |name, col_schema|
    type_hash = db.column_schema_to_ruby_type(col_schema)

    if type_hash == {:type => String}
      # There's no corresponding ruby type, as per:
      # <https://github.com/jeremyevans/sequel/blob/a2cfbb9/lib/sequel/
      #  extensions/schema_dumper.rb#L59-L61>
      # Copy over the column from db_generator_explicit.
      index = db_generator.columns.find_index {|c| c[:name] == name}
      col = db_generator_explicit.columns.find {|c| c[:name] == name}
      db_generator.columns[index] = col
    end
  end

  db_generator
end