Adding Components
Persistence Engine
Contributing an object persistence library is probably the most involved
component to integrate with Padrino. It touches the generators, the rake tasks
and the admin dashboard. For this guide, let's pretend we want to add a new ORM
called myorm, and use the existing
Sequel component as the working reference at
each step.
You don't need a generator component to use an ORM with Padrino. Any library
without one (ROM, for example) can be added to your Gemfile and set up by hand
in config/database.rb, like any other gem.
Generators
The available ORM choices are not listed anywhere by hand. Padrino builds them
from the files in padrino-gen/lib/padrino-gen/generators/components/orms/, in
padrino-gen/generators/actions.rb:
# padrino-gen/lib/padrino-gen/generators/actions.rb
def defines_component_options(options = {})
[
[:orm, 'database engine', { aliases: '-d', default: :none }],
# ...truncated...
].each do |name, caption, opts|
opts[:default] = '' if options[:default] == false
component_option name, caption, opts.merge(choices: Dir["#{__dir__}/components/#{name.to_s.pluralize}/*.rb"].map { |lib| File.basename(lib, '.rb').to_sym })
end
end
So adding a file named components/orms/myorm.rb is enough to make
padrino g project demo --orm=myorm (or -d myorm) a valid choice. The file
name is the component name everywhere else too: in the project's .components
file, in the generated Rakefile and in the admin generator.
The component file is not a class. The generator reads it and runs it with
instance_eval, so the methods it defines become methods of the running
generator.
An ORM component defines four methods:
setup_ormruns when a project is generated with--orm=myorm, or when the ORM is added later withpadrino g component --orm=myorm.create_model_file(name, options)runs frompadrino g model.create_model_migration(migration_name, name, columns)also runs frompadrino g model, unless--skip-migrationis given.create_migration_file(migration_name, name, columns)runs frompadrino g migration.
The model and migration generators call all of these, so define each one. An ORM without migrations leaves the last two empty, as the Mongoid and Ohm components do.
Here is the Sequel component, padrino-gen/generators/components/orms/sequel.rb:
# padrino-gen/lib/padrino-gen/generators/components/orms/sequel.rb
SEQUEL = <<~SEQUEL unless defined?(SEQUEL)
Sequel::Model.raise_on_save_failure = false # Do not throw exceptions on failure
Sequel::Model.db = case Padrino.env
when :development then Sequel.connect(!DB_DEVELOPMENT!, loggers: [logger])
when :production then Sequel.connect(!DB_PRODUCTION!, loggers: [logger])
when :test then Sequel.connect(!DB_TEST!, loggers: [logger])
end
SEQUEL
def setup_orm
sequel = SEQUEL.dup # a copy: frozen-string-literal mode rejects gsub! on the constant
db = @project_name.underscore
require_dependencies 'sequel'
begin
case adapter ||= options[:adapter]
# ...truncated: mysql-gem, mysql/mysql2 and postgres...
when 'sqlite'
sequel.gsub!(/!DB_DEVELOPMENT!/, "\"sqlite://db/#{db}_development.db\"")
sequel.gsub!(/!DB_PRODUCTION!/, "\"sqlite://db/#{db}_production.db\"")
sequel.gsub!(/!DB_TEST!/, "\"sqlite://db/#{db}_test.db\"")
require_dependencies 'sqlite3'
else
say "Failed to generate `config/database.rb` for ORM adapter `#{adapter}`", :red
raise ArgumentError
end
rescue ArgumentError
adapter = ask_valid_option(:adapter, %w[mysql mysql2 mysql-gem postgres sqlite], 'Please, choose a proper adapter:')
retry
end
# ...truncated: the Falcon fiber setting...
create_file('config/database.rb', sequel)
empty_directory('db/migrate')
end
SQ_MODEL = <<~MODEL unless defined?(SQ_MODEL)
class !NAME! < Sequel::Model
end
MODEL
# options => { fields: ['title:string', 'body:string'], app: 'app' }
def create_model_file(name, options = {})
model_path = destination_root(options[:app], 'models', "#{name.to_s.underscore}.rb")
model_contents = SQ_MODEL.gsub(/!NAME!/, name.to_s.underscore.camelize)
create_file(model_path, model_contents)
end
# ...truncated: SQ_MIGRATION, SQ_MODEL_UP_MG, SQ_MODEL_DOWN_MG...
def create_model_migration(migration_name, name, columns)
output_model_migration(
migration_name, name, columns,
column_format: proc { |field, kind| "#{kind.underscore.camelize} :#{field}" },
base: SQ_MIGRATION,
up: SQ_MODEL_UP_MG,
down: SQ_MODEL_DOWN_MG
)
end
# ...truncated: SQ_CHANGE_MG...
def create_migration_file(migration_name, name, columns)
output_migration_file(
migration_name, name, columns,
base: SQ_MIGRATION, change_format: SQ_CHANGE_MG,
add: proc { |field, kind| "add_column :#{field}, #{kind.underscore.camelize}" },
remove: proc { |field, _kind| "drop_column :#{field}" }
)
end
A few things to note:
require_dependenciesadds gems to the project'sGemfile.options[:adapter]is the project generator's--adapteroption (sqliteby default). Only SQL ORMs need it.- The model generator passes
fields: [...]andapp: ...tocreate_model_file. Write the model intodestination_root(options[:app], 'models', ...)so thatpadrino g modelwith-aputs it in the right app. - The
SQ_*constants are text templates with!PLACEHOLDER!markers. The shared helpersoutput_model_migrationandoutput_migration_file, in padrino-gen/generators/components/actions.rb, fill in!UP!,!DOWN!,!TABLE!,!FIELDS!and!COLUMNS!, number the file and write it todb/migrate. Ifmyormhas migrations, reuse these helpers rather than writing your own.
Rake Tasks
If the ORM needs rake tasks (to create or migrate the database, for instance),
add them in padrino-gen/lib/padrino-gen/padrino-tasks/myorm.rb. Every file in
that folder is registered automatically. For Sequel, see
padrino-tasks/sequel.rb:
# padrino-gen/lib/padrino-gen/padrino-tasks/sequel.rb
if PadrinoTasks.load?(:sequel, defined?(Sequel))
namespace :sq do
namespace :migrate do
# ...truncated: auto, to, up and down...
end
desc 'Perform migration up to latest migration available'
task migrate: 'sq:migrate:up'
desc 'Create the database'
task create: :skeleton do
# ...truncated...
end
# ...truncated: drop, reset and seed...
end
task 'db:create' => 'sq:create'
task 'db:migrate' => 'sq:migrate'
task 'db:reset' => 'sq:reset'
end
The generated project's Rakefile calls PadrinoTasks.use(:myorm), using the
component's file name, so pass the same symbol to PadrinoTasks.load?. Keep the
tasks in their own namespace and alias the common db:* names to them, so
rake db:migrate works whatever the ORM. rake db:seed is shared and already
provided by padrino-tasks/database.rb.
Unit Tests
Next, add tests for the new component in padrino-gen/test, next to the
existing Sequel ones. The project generator tests check the Gemfile and
config/database.rb, in
padrino-gen/test/test_project_generator.rb:
# padrino-gen/test/test_project_generator.rb
it 'should properly generate postgres' do
capture_io { generate(:project, 'sample_project', "--root=#{@apptmp}", '--orm=sequel', '--adapter=postgres') }
assert_match_in_file(/gem 'pg'/, "#{@apptmp}/sample_project/Gemfile")
assert_match_in_file(%r{"postgres://}, "#{@apptmp}/sample_project/config/database.rb")
assert_match_in_file(/sample_project_development/, "#{@apptmp}/sample_project/config/database.rb")
end
The model generator tests generate a project and then a model inside it, in padrino-gen/test/test_model_generator.rb:
# padrino-gen/test/test_model_generator.rb
it 'should generate migration file with given properties' do
stop_time_for_test
capture_io { generate(:project, 'sample_project', "--root=#{@apptmp}", '--script=none', '-d=sequel') }
capture_io { generate(:model, 'friend', 'name:string', 'age:integer', 'created:datetime', "-r=#{@apptmp}/sample_project") }
migration_file_path = "#{@apptmp}/sample_project/db/migrate/001_create_friends.rb"
assert_match_in_file(/class Friend < Sequel::Model/m, "#{@apptmp}/sample_project/models/friend.rb")
assert_match_in_file(/Sequel\.migration do/m, migration_file_path)
assert_match_in_file(/create_table :friends/m, migration_file_path)
assert_match_in_file(/String :name/m, migration_file_path)
assert_match_in_file(/Integer :age/m, migration_file_path)
assert_match_in_file(/DateTime :created/m, migration_file_path)
assert_match_in_file(/drop_table :friends/m, migration_file_path)
end
If myorm has migrations, also cover padrino g migration the same way, like
the the migration generator for sequel block in
padrino-gen/test/test_migration_generator.rb.
README
Add the new choice to the orm:: line of the
padrino-gen README.
With that, the generator side is done. To be fully supported, the ORM should
also work with Padrino Admin, so that padrino g admin and
padrino g admin_page generate a working dashboard. This is highly recommended.
Padrino Admin Support
First, add :myorm to the list returned by supported_orm in
padrino-admin/generators/actions.rb.
padrino g admin refuses to run for an ORM that isn't in this list.
Next, teach the Orm class in
padrino-admin/generators/orm.rb
how to talk to your models. The admin controller and view templates call its
methods to get the Ruby code they write out. Most of them branch on the ORM
name. These are the Sequel branches:
# padrino-admin/lib/padrino-admin/generators/orm.rb
def columns
@columns ||=
case orm
# ...truncated...
when :sequel then @klass.db_schema.map { |k, v| v[:type] = :text if v[:db_type] =~ /^text/i; Column.new(k, v[:type]) }
# ...truncated...
else raise OrmError, "Adapter #{orm} is not yet supported!"
end
end
def find(params = nil)
case orm
# ...truncated...
when :sequel, :ohm then "#{klass_name}[#{params}]"
else raise OrmError, "Adapter #{orm} is not yet supported!"
end
end
def save
orm == :sequel ? "(@#{name_singular}.save rescue false)" : "@#{name_singular}.save"
end
def update_attributes(params = nil)
case orm
# ...truncated...
when :sequel then "@#{name_singular}.modified! && @#{name_singular}.update(#{params})"
else raise OrmError, "Adapter #{orm} is not yet supported!"
end
end
columns, find and update_attributes raise an error for an unknown ORM, so
myorm needs a branch in each. columns must return objects that respond to
name and type; the Column struct defined in the same file works. The
other methods have a default branch that suits ActiveRecord-like libraries.
Check each one and add a branch where the default is wrong for myorm:
save,destroy;find_by_ids,multiple_destroy(bulk delete on the index page);has_error(form field errors).
all and build are the same for every ORM.
Next, add the Account model template that padrino g admin writes to
models/account.rb:
padrino-admin/lib/padrino-admin/generators/templates/account/myorm.rb.tt.
Use
account/sequel.rb.tt
as the model to follow. The rest of the admin expects the template to provide:
- the
name,surname,email,crypted_passwordandrolefields (the admin generator also runspadrino g modelwith these, which creates the migration); passwordandpassword_confirmationaccessors, with validations, and a callback that stores a hash of the password incrypted_password(the existing templates use BCrypt, which the admin generator adds to theGemfile);self.authenticate(email, password), used by the admin sessions controller;self.find_by_id(id), used to load the logged-in account;new,valid?,saveanderrors.full_messages, used by the generateddb/seeds.rb.
padrino-admin/lib/padrino-admin/generators/admin_app.rb also has a few
optional per-ORM touches: extra middleware (ActiveRecord adds
ConnectionPoolManagement) and the "next steps" message, which adds
rake db:create and rake db:migrate for the SQL ORMs it lists.
Add a test to padrino-admin/test/generators/test_account_model_generator.rb:
# padrino-admin/test/generators/test_account_model_generator.rb
describe 'sequel' do
before do
capture_io { generate(:project, 'sample_project', '-e=slim', "--root=#{@apptmp}", '-d=sequel') }
capture_io { generate(:admin_app, "--root=#{@apptmp}/sample_project") }
@model = "#{@apptmp}/sample_project/models/account.rb"
end
it 'should be a sequel model instance' do
assert_match_in_file(/class Account < Sequel::Model/m, @model)
end
end
Finally, add the ORM to the Orm Agnostic line of the
padrino-admin README,
and to the adapter list in the dashboard's base/index templates (one per
renderer, under padrino-admin/lib/padrino-admin/generators/templates).
On master, orm.rb also has a MODEL_BASES table, which maps each ORM to its
model base class (sequel: 'Sequel::Model'). padrino g admin_page uses it to
check that a model belongs to the project's ORM. Add an entry for myorm there
too.
Contribute to Padrino
Before sending the pull request, generate a real project with
--orm=myorm, add a model and run padrino g admin, then boot it and log in
to the dashboard. Once that works and the tests pass, send us a pull request
from your GitHub fork. If everything works when generated, we will include the
component in the next Padrino version and credit you for the contribution!