Skip to content

Instantly share code, notes, and snippets.

@sambostock
Created October 6, 2018 05:14
Show Gist options
  • Save sambostock/2d20944868e92a77f9c74beef38c133a to your computer and use it in GitHub Desktop.
Save sambostock/2d20944868e92a77f9c74beef38c133a to your computer and use it in GitHub Desktop.
ActiveRecord association test DSL

Association Test

The other day, I found myself having to change configuration for some associations. Not wanting to break things, I decided to write some tests to explicitly describe how things are hooked up. While doing this, I decided to try my hand at writing a simple DSL to reduce boilerplate in my tests.

Usage

Simply extend AssociationTest in your test class (which must be a child of ActiveSupport::TestCase), and use the DSL.

Example

model SomeModel do
  primary_key :id
  belongs_to :other_model, primary_key: :id, foreign_key: :other_model_id
end

effectively generates

test 'SomeModel uses id as primary_key' do
  assert_equal 'id', primary_key.to_s
end

test 'SomeModel belongs_to other_model using id as primary_key' do
  assert_equal 'id', SomeModel.reflect_on_association(:other_model).join_primary_key.to_s
end

test 'SomeModel belongs_to other_model using id as foreign_key' do
  assert_equal 'id', SomeModel.reflect_on_association(:other_model).join_foreign_key.to_s
end

test 'SomeModel.joins(:other_model) works' do
  SomeModel.joins(:other_model).load
end

Demo

ruby demo.rb --verbose

This will install the relevant gems using bundler/inline, as well as setup an in-memory database. If the gem installation fails, ensure you are not using the system Ruby, and have permissions to install gems.

You can increase logging by toggling comments on the following lines:

  • ui: Bundler::UI::Silent.new, in setup.rb
  • # ActiveRecord::Base.logger = Logger.new(STDOUT) in database.rb

Caveats and Details

For simplicity, this only implements the belongs_to and has_many associations. Additionally, the various *_key methods on AssociationReflection are super confusing, so this uses join_primary_key and join_foreign_key. It should be noted that these that these are dependent on the direction the join is going. While the names match for belongs_to associations, for has_many we must invert the names as we are joining in the opposite direction and foreign in this context refers to the column in the other table.

# frozen_string_literal: true
module AssociationTest
class Context
def initialize(model:, context:)
@model = model
@context = context
end
private
attr_reader :model
attr_reader :context
def primary_key(column_name)
primary_key = model.primary_key
context.test "#{model} uses #{column_name} as primary key" do
assert_equal column_name.to_s, primary_key.to_s
end
end
def belongs_to(association_name, primary_key:, foreign_key:)
association = model.reflect_on_association(association_name)
relation = model.joins(association_name)
context.test "#{model} belongs_to #{association_name} using #{primary_key} as primary_key" do
assert_equal primary_key.to_s, association.join_primary_key.to_s
end
context.test "#{model} belongs_to #{association_name} using #{foreign_key} as foreign_key" do
assert_equal foreign_key.to_s, association.join_foreign_key.to_s
end
context.test "#{model}.joins(:#{association_name}) works" do
relation.load
end
end
# Direction of join_primary_key/join_foreign_key reflect which direction
# we're joining in, so we reverse their use based on which direction we're
# testing the association in
def has_many(association_name, primary_key:, foreign_key:)
association = model.reflect_on_association(association_name)
relation = model.joins(association_name)
context.test "#{model} has_many #{association_name} using #{primary_key} as primary_key" do
assert_equal primary_key.to_s, association.join_foreign_key.to_s
end
context.test "#{model} has_many #{association_name} using #{foreign_key} as foreign_key" do
assert_equal foreign_key.to_s, association.join_primary_key.to_s
end
context.test "#{model}.joins(:#{association_name}) works" do
relation.load
end
end
end
def model(klass, &block)
Context.new(model: klass, context: self).instance_eval(&block)
end
end
# frozen_string_literal: true
require 'active_record'
require 'logger'
# Uncomment these lines to log all the things (e.g. queries)
# ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Schema.define do
# Comment this for table creation logs
self.verbose = false
create_table :id_resources
create_table :should_be_uuid_resources do |t|
t.string :uuid, index: true, null: false
end
create_table :nearly_uuid_resources do |t|
t.string :uuid, index: true, null: false
end
create_table :actually_uuid_resources, id: :string, primary_key: :uuid
create_table :linked_resources do |t|
t.string :id_resource_id, index: true
t.string :should_be_uuid_resource_id, index: true
t.string :nearly_uuid_resource_uuid, index: true
t.string :actually_uuid_resource_uuid, index: true
end
end
# frozen_string_literal: true
require_relative 'setup'
require_relative 'database'
require_relative 'models'
require_relative 'association_test'
require 'active_support/test_case'
require 'minitest/autorun'
class DemoTest < ActiveSupport::TestCase
extend AssociationTest
i_suck_and_my_tests_are_order_dependent! # only doing this for demo
model IdResource do
primary_key :id
has_many :linked_resources, primary_key: :id, foreign_key: :id_resource_id
end
model ActuallyUuidResource do
primary_key :uuid
has_many :linked_resources, primary_key: :uuid, foreign_key: :actually_uuid_resource_uuid
end
model ShouldBeUuidResource do
primary_key :id
has_many :linked_resources, primary_key: :id, foreign_key: :should_be_uuid_resource_id
end
model NearlyUuidResource do
primary_key :uuid
has_many :linked_resources, primary_key: :uuid, foreign_key: :nearly_uuid_resource_uuid
end
model LinkedResource do
primary_key :id
belongs_to :id_resource, primary_key: :id, foreign_key: :id_resource_id
belongs_to :actually_uuid_resource, primary_key: :uuid, foreign_key: :actually_uuid_resource_uuid
belongs_to :should_be_uuid_resource, primary_key: :id, foreign_key: :should_be_uuid_resource_id
belongs_to :nearly_uuid_resource, primary_key: :uuid, foreign_key: :nearly_uuid_resource_uuid
end
end
# frozen_string_literal: true
class IdResource < ActiveRecord::Base
has_many :linked_resources
end
class ShouldBeUuidResource < ActiveRecord::Base
has_many :linked_resources, foreign_key: :should_be_uuid_resource_id
end
class NearlyUuidResource < ActiveRecord::Base
self.primary_key = :uuid
has_many :linked_resources, foreign_key: :nearly_uuid_resource_uuid
end
class ActuallyUuidResource < ActiveRecord::Base
has_many :linked_resources, foreign_key: :actually_uuid_resource_uuid
end
class LinkedResource < ActiveRecord::Base
belongs_to :id_resource
belongs_to :actually_uuid_resource, foreign_key: :actually_uuid_resource_uuid
belongs_to :should_be_uuid_resource, foreign_key: :should_be_uuid_resource_id
belongs_to :nearly_uuid_resource, foreign_key: :nearly_uuid_resource_uuid
end
# frozen_string_literal: true
begin
require 'bundler/inline'
rescue LoadError => e
$stderr.puts 'Bundler version 1.10 or later is required. Please update your Bundler'
raise e
end
require 'bundler'
gemfile(
true,
# Omit the ui: option to include gem logs
ui: Bundler::UI::Silent.new,
) do
source 'https://rubygems.org'
gem 'activerecord'
gem 'activesupport'
gem 'sqlite3'
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment