Back to Repositories

Implementing Database Schema Testing in PaperTrail

This migration file sets up test tables for the PaperTrail gem, establishing a comprehensive database structure for testing versioning functionality. It creates various tables to test different scenarios including model tracking, custom version tables, and specialized data types.

Test Coverage Overview

The test suite provides extensive coverage of PaperTrail’s versioning capabilities across multiple database scenarios.

Key areas tested include:
  • Basic CRUD operations tracking
  • Custom version table configurations
  • Multiple database adapter support (MySQL, PostgreSQL)
  • STI (Single Table Inheritance) compatibility
  • Complex relationship tracking

Implementation Analysis

The testing approach utilizes a comprehensive migration structure to create test tables that simulate real-world scenarios. The implementation includes specialized configurations for different database adapters, particularly MySQL and PostgreSQL, with careful attention to data type limitations and character encoding requirements.

Technical Details

Key technical components include:
  • ActiveRecord::Migration::Current compatibility
  • Custom text byte limits (1_073_741_823)
  • Database-specific table options
  • Index configurations for performance
  • Various data type testing (JSON, JSONB for PostgreSQL)

Best Practices Demonstrated

The test suite demonstrates several testing best practices:

  • Comprehensive schema versioning
  • Database adapter-specific optimizations
  • Proper index management
  • Clear table structure organization
  • Careful handling of null constraints

paper-trail-gem/paper_trail

spec/dummy_app/db/migrate/20110208155312_set_up_test_tables.rb

            
# frozen_string_literal: true

# Parts of this migration must be kept in sync with
# `lib/generators/paper_trail/templates/create_versions.rb`
#
# Starting with AR 5.1, we must specify which version of AR we are using.
# I tried using `const_get` but I got a `NameError`, then I learned about
# `::ActiveRecord::Migration::Current`.
class SetUpTestTables < ::ActiveRecord::Migration::Current
  MYSQL_ADAPTERS = [
    "ActiveRecord::ConnectionAdapters::MysqlAdapter",
    "ActiveRecord::ConnectionAdapters::Mysql2Adapter"
  ].freeze
  TEXT_BYTES = 1_073_741_823

  def up
    create_table :on_create, force: true do |t|
      t.string :name, null: false
    end

    create_table :on_destroy, force: true do |t|
      t.string :name, null: false
    end

    create_table :on_empty_array, force: true do |t|
      t.string :name, null: false
    end

    create_table :on_touch, force: true do |t|
      t.string :name, null: false
    end

    create_table :on_update, force: true do |t|
      t.string :name, null: false
    end

    # Classes: Vehicle, Car, Truck
    create_table :vehicles, force: true do |t|
      t.string :name, null: false
      t.string :type, null: false
      t.integer :owner_id
      t.timestamps null: false, limit: 6
    end

    create_table :skippers, force: true do |t|
      t.string     :name
      t.datetime   :another_timestamp, limit: 6
      t.timestamps null: true, limit: 6
    end

    create_table :gizmos, force: true do |t|
      t.string :name
      t.timestamps null: true, limit: 6
    end

    create_table :widgets, force: true do |t|
      t.string    :name
      t.text      :a_text
      t.integer   :an_integer
      t.float     :a_float
      t.decimal   :a_decimal, precision: 6, scale: 4
      t.datetime  :a_datetime, limit: 6
      t.time      :a_time
      t.date      :a_date
      t.boolean   :a_boolean
      t.string    :type
      t.timestamps null: true, limit: 6
    end

    if ENV["DB"] == "postgres"
      create_table :postgres_users, force: true do |t|
        t.string     :name
        t.integer    :post_ids,    array: true
        t.datetime   :login_times, array: true, limit: 6
        t.timestamps null: true, limit: 6
      end
    end

    create_table :versions, **versions_table_options do |t|
      t.string   :item_type, **item_type_options(null: false)
      t.bigint   :item_id, null: false
      t.string   :item_subtype, **item_type_options(null: true)
      t.string   :event, null: false
      t.string   :whodunnit
      t.text     :object, limit: TEXT_BYTES
      t.text     :object_changes, limit: TEXT_BYTES
      t.integer  :transaction_id
      t.datetime :created_at, limit: 6

      # Metadata columns.
      t.integer :answer
      t.string :action
      t.string  :question
      t.integer :article_id
      t.string :title

      # Controller info columns.
      t.string :ip
      t.string :user_agent
    end
    add_index :versions, %i[item_type item_id]

    create_table :post_versions, force: true do |t|
      t.string   :item_type, null: false
      t.integer  :item_id,   null: false
      t.string   :event,     null: false
      t.string   :whodunnit
      t.text     :object
      t.datetime :created_at, limit: 6

      # Controller info columns.
      t.string :ip
      t.string :user_agent
    end
    add_index :post_versions, %i[item_type item_id]

    # Requires whodunnit column.
    create_table :comment_versions, force: true do |t|
      t.string   :item_type, null: false
      t.integer  :item_id,   null: false
      t.string   :event,     null: false
      t.string   :whodunnit, null: false
      t.text     :object
      t.datetime :created_at, limit: 6
    end
    add_index :comment_versions, %i[item_type item_id]

    # Uses custom versions table `no_object_versions`.
    create_table :no_objects, force: true do |t|
      t.string :letter, null: false, limit: 1
      t.timestamps null: false, limit: 6
    end

    # This table omits the `object` column.
    create_table :no_object_versions, force: true do |t|
      t.string   :item_type, null: false
      t.integer  :item_id,   null: false
      t.string   :event,     null: false
      t.string   :whodunnit
      t.datetime :created_at, limit: 6
      t.text     :object_changes, limit: TEXT_BYTES
      t.integer  :metadatum
    end
    add_index :no_object_versions, %i[item_type item_id]

    if ENV["DB"] == "postgres"
      %w[json jsonb].each do |j|
        table_name = j + "_versions"
        create_table table_name, force: true do |t|
          t.string   :item_type, null: false
          t.bigint   :item_id, null: false
          t.string   :event, null: false
          t.string   :whodunnit
          t.public_send j, :object
          t.public_send j, :object_changes
          t.datetime :created_at, limit: 6
        end
        add_index table_name, %i[item_type item_id]
      end
    end

    create_table :not_on_updates, force: true do |t|
      t.string :name
      t.timestamps null: true, limit: 6
    end

    create_table :bananas, force: true do |t|
      t.timestamps null: true, limit: 6
    end

    create_table :banana_versions, force: true do |t|
      t.string   :item_type, null: false
      t.integer  :item_id,   null: false
      t.string   :event,     null: false
      t.string   :whodunnit
      t.text     :object
      t.datetime :created_at, limit: 6
    end
    add_index :banana_versions, %i[item_type item_id]

    create_table :wotsits, force: true do |t|
      t.integer :widget_id
      t.string  :name
      t.timestamps null: true, limit: 6
    end

    create_table :fluxors, force: true do |t|
      t.integer :widget_id
      t.string  :name
    end

    create_table :whatchamajiggers, force: true do |t|
      t.string  :owner_type
      t.integer :owner_id
      t.string  :name
    end

    create_table :articles, force: true do |t|
      t.string :title
      t.string :content
      t.string :abstract
      t.string :file_upload
    end

    create_table :books, force: true do |t|
      t.string :title
    end

    create_table :authorships, force: true do |t|
      t.integer :book_id
      t.integer :author_id
    end

    create_table :people, force: true do |t|
      t.string :name
      t.string :time_zone
      t.integer :mentor_id
    end

    create_table :editorships, force: true do |t|
      t.integer :book_id
      t.integer :editor_id
    end

    create_table :editors, force: true do |t|
      t.string :name
    end

    create_table :songs, force: true do |t|
      t.integer :length
    end

    create_table :posts, force: true do |t|
      t.string :title
      t.string :content
    end

    create_table :comments, force: true do |t|
      t.string :content
    end

    create_table :post_with_statuses, force: true do |t|
      t.integer :status
      t.timestamps null: false, limit: 6
    end

    create_table :animals, force: true do |t|
      t.string :name
      t.string :species # single table inheritance column
    end

    create_table :pets, force: true do |t|
      t.integer :owner_id
      t.integer :animal_id
    end

    create_table :plants, force: true do |t|
      t.string :species # custom single table inheritance column
    end

    create_table :documents, force: true do |t|
      t.string :name
    end

    create_table :legacy_widgets, force: true do |t|
      t.string    :name
      t.integer   :version
    end

    create_table :things, force: true do |t|
      t.string    :name
      t.references :person
    end

    create_table :translations, force: true do |t|
      t.string    :content
      t.string    :draft_status
      t.string    :headline
      t.string    :language_code
    end

    create_table :gadgets, force: true do |t|
      t.string    :name
      t.string    :brand
      t.string    :color
      t.timestamps null: true, limit: 6
    end

    create_table :customers, force: true do |t|
      t.string :name
      t.datetime :touched_at, limit: 6
    end

    create_table :orders, force: true do |t|
      t.integer :customer_id
      t.string  :order_date
    end

    create_table :line_items, force: true do |t|
      t.integer :order_id
      t.string  :product
    end

    create_table :fruits, force: true do |t|
      t.string :color
      t.integer :mass
      t.string :name
      t.text :supplier
    end

    create_table :boolits, force: true do |t|
      t.string :name
      t.boolean :scoped, default: true
    end

    create_table :callback_modifiers, force: true do |t|
      t.string  :some_content
      t.boolean :deleted, default: false
    end

    create_table :chapters, force: true do |t|
      t.string :name
    end

    create_table :sections, force: true do |t|
      t.integer :chapter_id
      t.string :name
    end

    create_table :paragraphs, force: true do |t|
      t.integer :section_id
      t.string :name
    end

    create_table :quotations, force: true do |t|
      t.integer :chapter_id
    end

    create_table :citations, force: true do |t|
      t.integer :quotation_id
    end

    create_table :foo_habtms, force: true do |t|
      t.string :name
    end

    create_table :bar_habtms, force: true do |t|
      t.string :name
    end

    create_table :bar_habtms_foo_habtms, force: true, id: false do |t|
      t.integer :foo_habtm_id
      t.integer :bar_habtm_id
    end
    add_index :bar_habtms_foo_habtms, [:foo_habtm_id]
    add_index :bar_habtms_foo_habtms, [:bar_habtm_id]

    # custom_primary_key_records use a uuid column (string)
    create_table :custom_primary_key_records, id: false, force: true do |t|
      t.column :uuid, :string, primary_key: true
      t.string :name
      t.timestamps null: true, limit: 6
    end

    # and custom_primary_key_record_versions stores the uuid in item_id, a string
    create_table :custom_primary_key_record_versions, force: true do |t|
      t.string   :item_type, null: false
      t.string   :item_id,   null: false
      t.string   :event,     null: false
      t.string   :whodunnit
      t.text     :object
      t.datetime :created_at, limit: 6
    end
    add_index :custom_primary_key_record_versions, %i[item_type item_id], name: "idx_cust_pk_item"

    create_table :family_lines do |t|
      t.integer :parent_id
      t.integer :grandson_id
    end

    create_table :families do |t|
      t.string :name
      t.string :type            # For STI support
      t.string :path_to_stardom # Only used for celebrity families
      t.integer :parent_id
      t.integer :partner_id
    end

    create_table :vegetables, force: true do |t|
      t.string :color
      t.integer :mass
      t.string :name
      t.text :supplier
    end

    create_table :users, force: true
  end

  def down
    # Not actually irreversible, but there is no need to maintain this method.
    raise ActiveRecord::IrreversibleMigration
  end

  private

  def item_type_options(null:)
    opt = { null: null }
    opt[:limit] = 191 if mysql?
    opt
  end

  def mysql?
    MYSQL_ADAPTERS.include?(connection.class.name)
  end

  def versions_table_options
    if mysql?
      { options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" }
    else
      {}
    end
  end
end