Rails compatible model with rom-rb
Ruby Object Mapper is an excellent Ruby gem implementing data mapper pattern. It can be used instead of / alongside ActiveRecord
library in the Ruby on Rails applications (rom-rb
website provides instructions how to set it up in the existing Rails application).
The Rails framework, already comes with built-in helpers (form_with
, text_field
, etc.) to help with scaffolding HTML forms powered by ActiveRecord
models. Turns out, those helpers are not reserved to work with ActiveRecord
models only - they work with any objects that implement a specific set of methods.
Let’s start with defining a base class for all repositories in the application:
# app/repositories/application_repository.rb
class ApplicationRepository < ROM::Repository::Root
auto_struct true
struct_namespace ::Entities
def find(id)
root.by_pk(id).one!
end
end
auto_struct
specifies whether relation tuples should be transformed into struct objectsstruct_namespace
provides a namespace where for all those struct objects leave
Now, let’s define a base class for all entities:
class Entity < ROM::Struct
extend ActiveModel::Naming
include ActiveModel::Conversion
def self.inherited(klass)
super
klass.transform_types { |t| t.omittable }
end
def self.model_name
ActiveModel::Name.new(self, nil, name.demodulize)
end
end
ActiveModel::Naming includes methods (model_name
, param_key
, route_key
, etc.) to use the object with form_with
and redirect_to
helpers. ActiveMode::Convertions adds a few extra ones (to_key
, to_param
) for routing parameters, and partial paths.
klass.transform_types { |t| t.omittable }
modifies extending class to make all attributes (inferred from a database schema or specified manually) optional when creating a new class instance - without that Entities::MyEntity.new
will raise Dry::Struct::Error
, and it will complain about missing attributes.
We also override model_name
as described here to exclude entities
from method names (i.e. project_path
instead of entities_project_path
) used to generate paths and URLs for the entities.
Now our existing entities (extending from the base Entity
class) can be used with Rails form helpers. For non-persisted ones (used by new
action in the controller), we need to add one more extra method to our ApplicationRepository
class:
# app/repositories/application_repository.rb
class ApplicationRepository < ROM::Repository::Root
...
def build(attributes = {})
root.mapper.model.new(attributes)
end
end
root.mapper.model
returns a class that was generated by repository based on one of the Entitites::
class. Keep in mind, it’s not a simple inheritance - Entities::Project
instance returned by ProjectRepository#find
method, is not an exact instance of Entities::Project
class from app/models/entities
directory, but another class (with the same name) built on top of it - check this example:
pry(main)> repo = ProjectRepository.new(ROM.env)
=> #<ProjectRepository struct_namespace=Entities auto_struct=true>
pry(main)> p = repo.find(1)
ROM[postgres] (0.9ms) SELECT "projects"."id", "projects"."name" FROM "projects" WHERE ("projects"."id" = 1) ORDER BY "projects"."id"
=> #<Entities::Project id=1 name="Test after update">
pry(main)> p.instance_of?(Entities::Project)
=> false
pry(main)> p.is_a?(Entities::Project)
=> true
pry(main)> p.class.ancestors
=> [Entities::Project,
Entities::Project,
Entity,
ActiveModel::Conversion,
ROM::Struct,
Dry::Struct,
Dry::Core::Equalizer::Methods,
#<Dry::Core::Equalizer:0x000056462808edb8>,
Dry::Core::Constants,
ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
ActiveSupport::ForkTracker::CoreExtPrivate,
ActiveSupport::ForkTracker::CoreExt,
ActiveSupport::ToJsonWithActiveSupportEncoder,
Object,
PP::ObjectMixin,
JSON::Ext::Generator::GeneratorMethods::Object,
ActiveSupport::Dependencies::Loadable,
ActiveSupport::Tryable,
Kernel,
BasicObject]
We can’t just call Entities::Project.new(id: 1)
as the base struct is not aware of the attributes it should have (those are inferred from the schema, and incorporated into the final class that is generated by the repository), we have to use Repository#build
method instead, i.e.
class ProjectsController < ApplicationController
def index
render :index, locals: { projects: projects.all }
end
def new
@project = repo.build
render :new
end
def edit
@project = repo.find(params[:id])
end
def create
project = repo.create(project_params)
if project
redirect_to project
else
@project = repo.build(project_params)
render :new
end
end
def update
project = repo.update(params[:id], project_params)
if project
redirect_to project
else
@project = repo.build(project_params)
render :edit
end
end
private
def repo
@repo ||= ProjectRepository.new(rom)
end
def project_params
params.require(:project).permit(:name).to_h.symbolize_keys
end
end
Treat it more like a sudo code example, rather than production-ready solution as it lacks proper validation, authentication, and authorization