Adding ActiveRecord Rake Tasks to a Gem
This post was originally published on my previous blog jer-k.github.io
Published on 2018-02-22
In my previous post I walked through using a gem to connect to another Rails application's database, but another
use case for connecting a gem to a database is for the development of the gem itself. Instead of having to create a
Rails application and install the gem to connect to the database to test your models, we can create local database for
only the gem by adding ActiveRecord's Rake tasks.
There will be a lot to go through so I'm going to break this down into two parts: the first being creating the gem and
enabling the usage of familiar tasks such as db:create
and db:migrate
, the second being setting up the testing
environment locally and with Docker for CI purposes.
Let's get started creating the gem!
bundler gem gem_with_data
bundler gem gem_with_data
First thing we need to do is add the dependencies to gem_with_data.gemspec
.
spec.add_dependency 'activerecord', '~> 5'
spec.add_development_dependency "bundler", "~> 1.15"
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "rspec", "~> 3.0"
spec.add_development_dependency 'pg', '~> 0.19'
spec.add_development_dependency 'pry', '~> 0.10'
spec.add_development_dependency 'dotenv', '~> 2.2'
spec.add_development_dependency 'railties', '~> 5'
spec.add_dependency 'activerecord', '~> 5'
spec.add_development_dependency "bundler", "~> 1.15"
spec.add_development_dependency "rake", "~> 10.0"
spec.add_development_dependency "rspec", "~> 3.0"
spec.add_development_dependency 'pg', '~> 0.19'
spec.add_development_dependency 'pry', '~> 0.10'
spec.add_development_dependency 'dotenv', '~> 2.2'
spec.add_development_dependency 'railties', '~> 5'
Knowing that we're going to need to configure the database, we'll go ahead and create config/database.yml
and .env
to allow flexibility in the configuration.
default: &default
adapter: postgresql
encoding: unicode
pool: 5
host: localhost
port: 5432
local: &local
host: <%= ENV['POSTGRES_HOST'] %>
username: <%= ENV['POSTGRES_USER'] %>
password: <%= ENV['POSTGRES_PASSWORD'] %>
development:
<<: *default
<<: *local
database: gem_with_database_development
test:
<<: *default
<<: *local
database: gem_with_database_test
default: &default
adapter: postgresql
encoding: unicode
pool: 5
host: localhost
port: 5432
local: &local
host: <%= ENV['POSTGRES_HOST'] %>
username: <%= ENV['POSTGRES_USER'] %>
password: <%= ENV['POSTGRES_PASSWORD'] %>
development:
<<: *default
<<: *local
database: gem_with_database_development
test:
<<: *default
<<: *local
database: gem_with_database_test
POSTGRES_USER=gem_with_database
POSTGRES_PASSWORD=password
POSTGRES_USER=gem_with_database
POSTGRES_PASSWORD=password
Ensure the user the database expects has been created.
$ psql postgres --command="create role gem_with_database with superuser login password 'password'"
$ psql postgres --command="create role gem_with_database with superuser login password 'password'"
Now we can create support/active_record_rake_tasks.rb
to configure ActiveRecord::Tasks::DatabaseTasks
and load the rake tasks.
support/active_record_rake_tasks.rb
# Add the ability to run db:create/migrate/drop etc
require 'yaml'
require 'erb'
require 'dotenv'
require 'active_record'
include ActiveRecord::Tasks
root = File.expand_path('../..', __FILE__)
DatabaseTasks.root = root
DatabaseTasks.db_dir = File.join(root, 'db')
DatabaseTasks.migrations_paths = [File.join(root, 'db/migrate')]
# Load the environment variables for the Postgres user
Dotenv.load('.env')
DatabaseTasks.database_configuration = YAML.load(ERB.new(IO.read(File.join(root, 'config/database.yml'))).result)
# The SeedLoader is Optional, if you don't want/need seeds you can skip setting it
class SeedLoader
def initialize(seed_file)
@seed_file = seed_file
end
def load_seed
load @seed_file if File.exist?(@seed_file)
end
end
DatabaseTasks.seed_loader = SeederLoader.new(File.join(root, 'db/seeds.rb'))
DatabaseTasks.env = ENV['ENV'] || 'development'
ActiveRecord::Base.configurations = DatabaseTasks.database_configuration
ActiveRecord::Base.establish_connection(DatabaseTasks.env.to_sym)
load 'active_record/railties/databases.rake'
support/active_record_rake_tasks.rb
# Add the ability to run db:create/migrate/drop etc
require 'yaml'
require 'erb'
require 'dotenv'
require 'active_record'
include ActiveRecord::Tasks
root = File.expand_path('../..', __FILE__)
DatabaseTasks.root = root
DatabaseTasks.db_dir = File.join(root, 'db')
DatabaseTasks.migrations_paths = [File.join(root, 'db/migrate')]
# Load the environment variables for the Postgres user
Dotenv.load('.env')
DatabaseTasks.database_configuration = YAML.load(ERB.new(IO.read(File.join(root, 'config/database.yml'))).result)
# The SeedLoader is Optional, if you don't want/need seeds you can skip setting it
class SeedLoader
def initialize(seed_file)
@seed_file = seed_file
end
def load_seed
load @seed_file if File.exist?(@seed_file)
end
end
DatabaseTasks.seed_loader = SeederLoader.new(File.join(root, 'db/seeds.rb'))
DatabaseTasks.env = ENV['ENV'] || 'development'
ActiveRecord::Base.configurations = DatabaseTasks.database_configuration
ActiveRecord::Base.establish_connection(DatabaseTasks.env.to_sym)
load 'active_record/railties/databases.rake'
Let's walk through what we've done and then we'll try it out! By including ActiveRecord::Tasks
we are able to start
configuring ActiveRecord::Tasks::DatabaseTasks. Looking at the attr_writer properties in DatabaseTasks
we can get a feel for the properties we need to set.
attr_writer :current_config, :db_dir, :migrations_paths, :fixtures_path, :root, :env, :seed_loader
attr_writer :current_config, :db_dir, :migrations_paths, :fixtures_path, :root, :env, :seed_loader
First, we'll set root
to the base directory of the gem, this mimics the effects of Rails.root
, which coincidentally
is exactly what the DatabaseTasks#root method calls. Next, we need to set the db_dir
and we'll do so by
mimicking the structure of a Rails project and having the directory be named db
and live under the root. Continuing
to have our setup look like a Rails project we'll create the db/migrate
directory and set it as the migrations_paths
;
note that its plural so we pass in an Array
and could specify more than one directory.
We'll load the environment variables needed for the database_configuration
and then make use of YAML
and ERB
to
interpret the database.yml
file. The next step is optional, but if we want to be able to use seeds, we have to define
a class that responds to load_seed.
Following the invocation in DatabaseTasks
we can see the method definition for load_seed.
def load_seed
seed_file = paths["db/seeds.rb"].existent.first
load(seed_file) if seed_file
end
def load_seed
seed_file = paths["db/seeds.rb"].existent.first
load(seed_file) if seed_file
end
Our SeedLoader
class will be initialized referencing to a file, which will be db/seeds.rb
just as in a Rails project.
In preparation for running the tests later we'll default the environment
to development
unless otherwise specified.
The last three things we need to do are set the ActiveRecord::Base.configurations
to our configured
DatabaseTasks.database_configuration
, use establish_connection
to the database using the environment we specified,
and then load active_record/railties/databases.rake to make the Rake tasks available.
Now we need to load our active_record_rake_tasks.rb
file in Rakefile
.
require './support/active_record_rake_tasks'
# Stub the :environment task for tasks like db:migrate & db:seed. Since this is a Gem we've explicitly required all
# dependent files in the needed places and we don't have to load the entire environment.
task :environment
require './support/active_record_rake_tasks'
# Stub the :environment task for tasks like db:migrate & db:seed. Since this is a Gem we've explicitly required all
# dependent files in the needed places and we don't have to load the entire environment.
task :environment
I stubbed out the task :environment
because some tasks like db:migrate explicitly require :environment
to be defined.
task migrate: [:environment, :load_config] do
ActiveRecord::Tasks::DatabaseTasks.migrate
db_namespace["_dump"].invoke
end
task migrate: [:environment, :load_config] do
ActiveRecord::Tasks::DatabaseTasks.migrate
db_namespace["_dump"].invoke
end
Let's see if it works...
$ rake db:create
Created database 'gem_with_database_development'
Created database 'gem_with_database_test'
$ rake db:migrate
$ rake db:drop
Dropped database 'gem_with_database_development'
Dropped database 'gem_with_database_test'
$ rake db:create
Created database 'gem_with_database_development'
Created database 'gem_with_database_test'
$ rake db:migrate
$ rake db:drop
Dropped database 'gem_with_database_development'
Dropped database 'gem_with_database_test'
I was able to run rake db:migrate
but we don't actually have any migrations; unfortunately rails generate
is not
available to us yet!
$ rails g migration create_author name:string age:integer
Usage:
rails new APP_PATH [options]
$ rails g migration create_author name:string age:integer
Usage:
rails new APP_PATH [options]
This result is due to the fact that I have the Rails gem globally installed so that I can create new Rails applications
in any directory. However, I don't want to bring the entirety of Rails into the gem so we're going to have to add this
ability ourselves. We'll create exe/gem_rails
to mimic the pattern used when creating a gem with a CLI.
#!/usr/bin/env ruby
require 'rails'
module GemWithDatabase
class Engine < Rails::Engine
config.generators do |g|
g.orm :active_record
end
end
end
Rails.application = GemWithDatabase::Engine
require 'rails/commands'
#!/usr/bin/env ruby
require 'rails'
module GemWithDatabase
class Engine < Rails::Engine
config.generators do |g|
g.orm :active_record
end
end
end
Rails.application = GemWithDatabase::Engine
require 'rails/commands'
The code required to get this running is a lot less than I expected and for the sake of brevity I'll just through what
the code is doing (I do however want to write about the process of figuring all this out. I'll follow this post with
that information. Follow up here).
The require rails
is not actually requiring all of Rails (as I mentioned I didn't want to do above) but only the
Rails module defined in Railties
. This gives us access to Rails::Engine, which we need to create our own.
Rails::Engine
in a subclass of Rails::Railtie which has a generators method.
def generators(&blk)
register_block_for(:generators, &blk)
end
def generators(&blk)
register_block_for(:generators, &blk)
end
By registering g.orm :active_record
, when our engine runs load_generators
def load_generators(app = self)
require "rails/generators"
run_generators_blocks(app)
Rails::Generators.configure!(app.config.generators)
self
end
def load_generators(app = self)
require "rails/generators"
run_generators_blocks(app)
Rails::Generators.configure!(app.config.generators)
self
end
it properly adds active_record:migration to our accessible generators. Now we can try to generate the migration again.
Don't forget make the file executable $ chmod 755 exe/gem_rails
.
$ exe/gem_rails g migration create_author name:string age:integer
invoke active_record
create db/migrate/20180228040040_create_author.rb
$ exe/gem_rails g migration create_author name:string age:integer
invoke active_record
create db/migrate/20180228040040_create_author.rb
Success! Let's look at the migration that was created and then migrate our database.
db/migrate/20180228040040_create_author.rb
class CreateAuthor < ActiveRecord::Migration[5.1]
def change
create_table :authors do |t|
t.string :name
t.integer :age
end
end
end
db/migrate/20180228040040_create_author.rb
class CreateAuthor < ActiveRecord::Migration[5.1]
def change
create_table :authors do |t|
t.string :name
t.integer :age
end
end
end
$ rake db:migrate
== 20180228040040 CreateAuthor: migrating =====================================
-- create_table(:authors)
-> 0.0308s
== 20180228040040 CreateAuthor: migrated (0.0309s) ============================
$ rake db:migrate
== 20180228040040 CreateAuthor: migrating =====================================
-- create_table(:authors)
-> 0.0308s
== 20180228040040 CreateAuthor: migrated (0.0309s) ============================
Awesome! I'll wrap up by seeding my database and then query for some data. To accomplish this I'll create the models,
create a migration for books, and the query the data.
$ exe/gem_rails g migration create_books title:string pages:integer published:integer author:references
invoke active_record
create db/migrate/20180228040533_create_books.rb
$ rake db:migrate
== 20180228040533 CreateBooks: migrating ======================================
-- create_table(:books)
-> 0.0494s
== 20180228040533 CreateBooks: migrated (0.0495s) =============================
$ rake db:seed
$ exe/gem_rails g migration create_books title:string pages:integer published:integer author:references
invoke active_record
create db/migrate/20180228040533_create_books.rb
$ rake db:migrate
== 20180228040533 CreateBooks: migrating ======================================
-- create_table(:books)
-> 0.0494s
== 20180228040533 CreateBooks: migrated (0.0495s) =============================
$ rake db:seed
$ bin/console
2.5.0 :001 > require 'active_record'
=> false
2.5.0 :002 > ActiveRecord::Base.establish_connection(
2.5.0 :003 > :adapter => 'postgresql',
2.5.0 :004 > :database => 'gem_with_database_development'
2.5.0 :005?> )
2.5.0 :006 > GemWithDatabase::Author.find_by(name: 'J.K. Rowling')
=> #<GemWithDatabase::Author id: 2, name: "J.K. Rowling", age: 50>
$ bin/console
2.5.0 :001 > require 'active_record'
=> false
2.5.0 :002 > ActiveRecord::Base.establish_connection(
2.5.0 :003 > :adapter => 'postgresql',
2.5.0 :004 > :database => 'gem_with_database_development'
2.5.0 :005?> )
2.5.0 :006 > GemWithDatabase::Author.find_by(name: 'J.K. Rowling')
=> #<GemWithDatabase::Author id: 2, name: "J.K. Rowling", age: 50>
We've successfully added all the ActiveRecord Rake tasks to our gem and have been able to create, migrate, seed, and query
our database! There is a repository for I work I did while writing this post. Feel free to try it out and be on
the lookout for some follow-up posts. I'll be writing in more detail about how I figured out what was needed for the
Rails::Engine
(post here) and then I'll continue working on this project setting up the testing environment locally
and then using Docker for CI purposes, along with a few enhancements to the scripts in bin/
.