How to use ActiveRecord in a library
— activerecord, library, ruby
Recently, I wanted to use ActiveRecord in a library, not in a Rails app or a Rails engine. After doing a bit of Googling, I found this excellent tutorial. While it covered the basics, such as connecting to the database, defining a model and a migration, it missed a few things. In this blog post I will provide a more detailed example of how to connect to a default or arbitrary database, “connect” multiple models to the database, and run a directory of migrations if they have not already been ran.
Connecting to the Database
require 'active_record'
ActiveRecord::Base.establish_connection(
adapter: 'sqlite3',
database: 'database.sqlite3'
)
If you want to connect to a separate database for tests, you can use an in-memory sqlite3 database:
ActiveRecord::Base.establish_connection(
adapter: 'sqlite3',
database: ':memory:'
)
Migrations
# lib/library/migrations/0001_create_authors_table.rb
class CreateAuthorsTable < ActiveRecord::Migration[7.0]
def change
create_table :library_authors do |t|
t.string :name, null: false
t.index :name, unique: true
end
end
end
# lib/library/migrations/0002_create_books_table.rb
class CreateBooksTable < ActiveRecord::Migration[7.0]
def change
create_table :library_books do |t|
t.string :title
t.index :title, unique: true
end
end
end
# lib/library/migrations/0003_create_book_authors_table.rb
class CreateBookAuthorsTable < ActiveRecord::Migration[7.0]
def change
create_table :library_book_authors do |t|
t.references :author, null: false,
foreign_key: {
to_table: :library_authors
}
t.references :book, null: false,
foreign_key: {
to_table: :library_books
}
t.index [:author_id, :book_id], unique: true
end
end
end
Note: due to how ActiveRecord
loads migration classes they must not be
defined in a module namespace and must not contain any all-uppercase acronym
words (ex: CreateIpAddressesTable
not CreateIPAddressesTable
).
Running Migrations
ActiveRecord provides a ActiveRecord::MigrationContext class
which can load and run migrations similar to rake db:migrate
. This class can
be initialized with our own migrations/
directory path, which will load all
migrations within the directory and allow us to run the migrations.
The next step is to define our own Migrations
module which defines
migrate
, migrate_up
, and migrate_down
methods which call the
.migrate
, .up
, and .down
methods on the initialized
ActiveRecord::MigrationContext
object:
# lib/library/migrations.rb
require_relative 'active_record'
module Library
module Migrations
DIR = File.join(__dir__,'migrations')
def self.context
@context ||= ActiveRecord::MigrationContext.new([DIR])
end
def self.migrate(target_version=nil)
context.migrate(target_version)
end
def self.migrate_up(target_version=nil)
context.up(target_version)
end
def self.migrate_down(target_version=nil)
context.down(target_version)
end
end
end
Models
# lib/library/author.rb
module Library
class Author < ActiveRecord::Base
self.table_name_prefix = 'library_'
attribute :name, :string
validates :name, presence: true,
uniqueness: true
has_many :authorships, class_name: 'BookAuthor'
has_many :books, through: :authorships,
inverse_of: :authors
end
end
Note: the explicit self.table_name_prefix
is required since ActiveRecord
is unable to infer the table prefix based on the module namespace.
# lib/library/book_author.rb
module Library
class BookAuthor < ActiveRecord::Base
self.table_name_prefix = 'library_'
belongs_to :book, required: true
belongs_to :author, required: true
end
end
Note: while the Internet says that all belongs_to
associations are
required by default since Rails 5, when using ActiveRecord outside of Rails
one must explicitly add required: true
to them.
# lib/library/book.rb
module Library
class Book < ActiveRecord::Base
self.table_name_prefix = 'library_'
attribute :title, :string
validates :title, presence: true,
uniqueness: true
has_many :book_authors
has_many :authors, through: :book_authors,
inverse_of: :books
end
end
To make it easy to load all models after establishing the database connection
(ActiveRecord does not allow you to define any model classes before calling
ActiveRecord::Base.establish_connection
), we also create a
models.rb
file:
# lib/library/models.rb
require_relative 'author'
require_relative 'book'
require_relative 'book_author'
module Library
MODELS = [
Author,
Book,
BookAuthor
]
end
ActiveRecord::Base.connection
In order for your model’s inspect
methods to work properly, the ActiveRecord
models need to be “connected” to the established database connection. This can
be done by calling connection
on each model class.
Library::Book.inspect
# => "Library::Book (call 'Library::Book.connection' to establish a connection)"
Library::Book.connection
Library::Book.inspect
# => "Library::Book(id: integer, title: string)"
Note: for some reason it appears that this extra step is not necessary if the migrations are ran before loading the models.
I am not really sure why this extra step is necessary, as it seems like
ActiveRecord could lazy-connect each model when ActiveRecord::Base#inspect
is
called.
Putting It All Together
Now we just need an entry-point method that can:
- Connect to the database
- Run migrations (if there are any pending migrations)
- “connect” the models
We shall call this entry-point method Library.connect
:
# lib/library.rb
module Library
DEFAULT_DATABASE = {
adapter: 'sqlite3',
database: '/path/to/default/database.sqlite3'
}
def self.connect(database=DEFAULT_DATABASE)
ActiveRecord::Base.establish_connection(database)
require_relative 'library/migrations'
Migrations.migrate_up
require_relative 'library/models'
MODELS.each(&:connection)
end
end
If you are interested, I have pushed the working code for this blog post to GitHub.