Creating the Rails Age Plugin

An explanation of somewhat confusing process of creating a rails engine

Creating a simple Rails Engine despite the variety of documentation was confusing, so here is what I learned.

Source Code: https://github.com/marpori/rails_age RubyGem Link: https://rubygems.org/gems/rails_age AGE Demo App: https://github.com/marpori/rails_age_demo_app

It’s important to set up a dummy app from the beginning (to simplify testing)

So after several starts I found the following worked to start the project (in my case the database must be postgresql):

rails plugin new rails_age -d=postgresql -T --mountable \
      --dummy-path=spec/dummy

I like to use rspec so I also used the -T option.

Now to add rspec I added: spec.add_development_dependency 'rspec-rails' to the gemspec file.

You need to update the rest of the gemspec too - so now mine looks like:

# rails_age.gemspec
require_relative "lib/rails_age/version"

Gem::Specification.new do |spec|
  spec.name        = "rails_age"
  spec.version     = RailsAge::VERSION
  spec.authors     = ["Bill Tihen"]
  spec.email       = ["btihen@gmail.com"]
  spec.homepage    = "https://github.com/marpori/rails_age"
  spec.summary     = "Apache AGE plugin for Rails 7.1"
  spec.description = spec.summary
  spec.license     = "MIT"

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = spec.homepage
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"

  spec.files = Dir.chdir(File.expand_path(__dir__)) do
    Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "CHANGELOG.md"]
  end

  spec.add_dependency "rails", ">= 7.1.3.2"

  spec.add_development_dependency 'rspec-rails'
end

Now run:

bundle install
rails generate rspec:install
bundle binstubs rspec-core

I wanted to create test files in my dummy app and in the gem so the first thing to do is copy the rails_helper.rb and spec_helper.rb into the dummy app.

mkdir spec/dummy/spec
cp spec/rails_helper.rb spec/dummy/spec/.
cp spec/spec_helper.rb spec/dummy/spec/.

Now it is important to change our original file spec/rails_helper.rb to point to the dummy environments (our gem / plugin has no such file), so we change the line: require_relative '../config/environment' to require File.expand_path('../dummy/config/environment', __FILE__)

In this way our test environment can find an environment - now this file looks like:

# spec/rails_helper.rb

require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
# require_relative '../config/environment'
require File.expand_path('../dummy/config/environment', __FILE__)
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'

# Checks for pending migrations and applies them before tests are run.
# If you are not using ActiveRecord, you can remove these lines.
begin
  ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
  abort e.to_s.strip
end
RSpec.configure do |config|
  # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
  config.fixture_paths = [
    Rails.root.join('spec/fixtures')
  ]

  # If you're not using ActiveRecord, or you'd prefer not to run each of your
  # examples within a transaction, remove the following line or assign false
  config.use_transactional_fixtures = true

  # You can uncomment this line to turn off ActiveRecord support entirely.
  # config.use_active_record = false

  # RSpec Rails can automatically mix in different behaviours to your testsx
  # The different available types are documented in the features, such as in
  # https://rspec.info/features/6-0/rspec-rails
  config.infer_spec_type_from_file_location!

  # Filter lines from Rails gems in backtraces.
  config.filter_rails_from_backtrace!
end

now in the gem root directory I create a migration to configure the database for ApacheAge.

rails generate migration ConfigureApacheAge

The migration looks like:

class ConfigureApacheAge < ActiveRecord::Migration[7.1]
  def up
    # Allow age extension
    execute('CREATE EXTENSION IF NOT EXISTS age;')

    # Load the age code
    execute("LOAD 'age';")

    # Load the ag_catalog into the search path
    execute('SET search_path = ag_catalog, "$user", public;')

    # Create age_schema graph if it doesn't exist
    execute("SELECT create_graph('age_schema');")
  end

  def down
    execute <<-SQL
      DO $$
      BEGIN
        IF EXISTS (
          SELECT 1
          FROM pg_constraint
          WHERE conname = 'fk_graph_oid'
        ) THEN
          ALTER TABLE ag_catalog.ag_label
          DROP CONSTRAINT fk_graph_oid;
        END IF;
      END $$;
    SQL

    execute("SELECT drop_graph('age_schema', true);")
    execute('DROP SCHEMA IF EXISTS ag_catalog CASCADE;')
    execute('DROP EXTENSION IF EXISTS age;')
  end
end

Now I can finally setup the database:

bin/rails db:create RAILS_ENV=test
bin/rails db:migrate RAILS_ENV=test

In my case, I need to write the schema myself as rails doesn’t handle the Apache Age extension changes properly. So I needed to rewrite the schema as:

ActiveRecord::Schema[7.1].define(version: 2024_05_21_062349) do
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"

  # Allow age extension
  execute('CREATE EXTENSION IF NOT EXISTS age;')

  # Load the age code
  execute("LOAD 'age';")

  # Load the ag_catalog into the search path
  execute('SET search_path = ag_catalog, "$user", public;')

  # Create age_schema graph if it doesn't exist
  execute("SELECT create_graph('age_schema');")
end

NOTE: the version number MUST match the migration filename - in my case the file is: db/migrate/20240521062349_configure_apache_age.rb so the version needs to be: 2024_05_21_062349!

I used (for better or worse a different namespace for my code as the gem)

mkdir lib/apache_age
# code files:
touch lib/apache_age/class_methods.rb
touch lib/apache_age/common_methods.rb
touch lib/apache_age/edge.rb
touch lib/apache_age/entity.rb
touch lib/apache_age/vertex.rb

Now it is important to update the file lib/rails_age.rb with all the new files:

# lib/rails_age.rb
require "rails_age/version"
require "rails_age/engine"

module RailsAge
  # Additional code goes here...
end

module ApacheAge
  require "apache_age/class_methods"
  require "apache_age/common_methods"
  require "apache_age/edge"
  require "apache_age/entity"
  require "apache_age/vertex"
end

Now I created my spec tests for the gem:

mkdir -p spec/lib/apache_age

touch spec/lib/apache_age/class_methods_spec.rb
touch spec/lib/apache_age/common_methods_spec.rb
touch spec/lib/apache_age/edge_spec.rb
touch spec/lib/apache_age/entity_spec.rb
touch spec/lib/apache_age/vertex_spec.rb

Now I can test with: bundle exec rspec spec or better yet using the binstub: bin/rspec spec

Optional Dummy App Testing

Now if you want you can go into spec/dummy and write / execute tests within the dummy rails app:

Add the Graph App files:

mkdir -p spec/dummy/app/graphs/edges
mkdir -p spec/dummy/app/graphs/nodes

spec/dummy/app/graphs/edges/works_at.rb
spec/dummy/app/graphs/nodes/company.rb
spec/dummy/app/graphs/nodes/person.rb

generate a controller and views:

bin/rails g scaffold_controller Person

Now we can add tests:

mkdir -p spec/dummy/spec/graphs/edges
mkdir -p spec/dummy/spec/graphs/nodes

spec/dummy/spec/graphs/edges/works_at_spec.rb
spec/dummy/spec/graphs/nodes/company_spec.rb
spec/dummy/spec/graphs/nodes/person_spec.rb

We need to run update the dummy app schema.rb with the same data as in the gem db/schema.rb so do:

cp db/schema.rb spec/dummy/db/schema.rb

now within the dummy app we can run our test:

spec/dummy/
bundle exec rspec spec

cool it works within a dummy app too!

Gem Usage Testing

assuming the tests are green, then we can build the gem with:

gem build rails_age.gemspec

add *.gem to the end of .gitignore file.

Now we can build a new project and try out our gem:

rails new graphdb_age_app -T -d=postgresql

now at the end of the Gemfile add:

# Gemfile
gem 'rails_age', path: '../rails_age'

and of course run bundle and test the new app with the plugin.

Gem Repo Publishing & Usage

Assuming this works, we can now push the repo live (first make a repo on github) in my case at: https://github.com/marpori/rails_age

then:

git add .
git commit -m "initial Rails Engine Plugin Commit"
git remote add origin git@github.com:marpori/rails_age.git
git branch -M main
git push -u origin main

Now we can change how we access the gem using:

# Gemfile
gem 'rails_age', git: 'https://github.com/marpori/rails_age.git'

and of course run bundle and test.

RubyGem Publishing & Usage

Assuming this still works well, we can publish the gem using - assuming you already have an account on rubygems

# be sure to update the version number before building a new version!
gem build rails_age.gemspec
gem signin
gem push rails_age-0.1.0.gem

Now the gem should also be usable with just gem 'rails_age' in the Gemfile:

# Gemfile
gem 'rails_age'

to the Gemfile & of course running: bundle all should still work and be published on rubygems at: https://rubygems.org/gems/rails_age

Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature