#!/usr/bin/env ruby
#!/usr/bin/env ruby
See all parts · View the source · Follow the blog · Say hi on Twitter
In part 1, we took our first steps with rom-rb, setting up our basic persistence API and some repositories, then creating and reading back records in our database.
This time, we’ll return query results in domain-specific objects, fetch records with associations, and also look at updating records.
require "bundler/setup"
require "rom-sql"
require "rom-repository"
We’re pulling in one new dependency this time: dry-types. dry-types works very well alongside rom-rb, and will allow us to build type-safe domain entites from our database records.
require "dry-types"
In part 1, we started with a simple articles relation and corresponding database table. Now we’ll expand things a little: we want our articles to have many categories, so we’ll add a categories relation and an articles_categories relation to join the two.
module Relations
class Articles < ROM::Relation[:sql]
schema(:articles) do
attribute :id, Types::Serial
attribute :title, Types::String
attribute :published, Types::Bool
We’ll be using rom-rb’s new associate
API to make building these
associations easy for us. Along with the schema
API, this is new in
rom-rb’s master branches and will be released later in June.
Basic usage of the associate
API is wonderfully simple. All we need
to do is declare the associations for this schema, and rom-rb will do
the work to connect things for us.
In this case, articles will have many categories through our articles_categories join table.
associate do
many :categories, through: :articles_categories
end
end
def by_id(id)
where(id: id)
end
def published
where(published: true)
end
end
Our new Categories relation is straightforward: just an ID and a name.
class Categories < ROM::Relation[:sql]
schema(:categories) do
attribute :id, Types::Serial
attribute :name, Types::String
end
end
Now let’s join records from these tables together.
class ArticlesCategories < ROM::Relation[:sql]
schema(:articles_categories) do
attribute :id, Types::Serial
The schema definition here has a couple of interesting things: these
ForeignKey
types. This is simply a way to leave some extra metadata
on the type annotations so rom-rb can know to use these foreign key
columns when it builds its associations.
By default, rom-rb ForeignKey
types are integers, but we can also
provide specific types like this:
Types::ForeignKey(:articles, type: Types::String)
attribute :article_id, Types::ForeignKey(:articles)
attribute :category_id, Types::ForeignKey(:categories)
And here we put these foreign keys to work by specifying the associations for this join table: each record belongs to both an article and a category.
associate do
belongs :articles
belongs :categories
end
end
end
end
Type safety and shareable type definitions are an important pillar of any dry-rb app, and here we can get a little taste of it. We’ll set up entity classes to model the results coming out of our databases. Using dry- types structs here gives us a number of benefits, including:
We can also feel free to pass these objects all around our app, since by convention we don’t mutate them, and unlike objects coming from the active record pattern, there’s no way to trigger far-reaching side effects, like database changes. These are also explicitly handled for us by rom-rb’s dedicated query and commands objects, which we would never use accidentally.
The first thing we need to do with dry-types is to give our app a Types
module to include all of dry-type’s out-of-the-box definitions, all the
basic Ruby types like strings, integers, etc.
module Types
include Dry::Types.module
end
Now we can refer to this types module when we set up our struct subclasses. Our Category struct has attributes to contain each of the values we expect to get from rows in its corresponding database table.
class Category < Dry::Types::Struct
attribute :id, Types::Strict::Int
attribute :name, Types::Strict::String
end
And Article is much the same, with one exception: we’re going to fetch each article along with all of its associated categories, so we set up a categories attribute, which is an array of Category objects. Here we see how we can actually build upon our existing struct classes to provide quite expressive, fully-featured type definitions.
class Article < Dry::Types::Struct
attribute :id, Types::Strict::Int
attribute :title, Types::Strict::String
attribute :published, Types::Strict::Bool
attribute :categories, Types::Strict::Array.member(Category)
end
With these types in place, we can revisit our repositories. One thing to note here is that, within this script, these repositories have now moved a little further away from the relations that they use. This is actually a fairer representation of how they should sit within a larger app: repositories are not part of the basic persistence layer, but are rather a way for your app to hide away the nitty-gritty details of persistence. In this way, they’re part of your app’s collection of core objects.
We’ll take this a step further today by configuring these repositories to return results wrapped up in the domain entities we defined just above.
module Repositories
While we’ve added 3 extra relations above, we’ll still be keeping only one repository, which is another reflection of the different roles these two things serve. Repositories are the sole interfaces through which our apps work with persisted data, and in the case of this playground, all we need to get are articles.
class Articles < ROM::Repository[:articles]
This repository can already access its root articles relation. Now we need to make the categories relation accessible to it, so it can take care of the association of articles to categories.
relations :categories
We’re also adding one new command to the repository: update
, combining
it with a by_id
restriction. by_id
is the query method we defined
in the articles relation, and it means here that the update command will
require an article ID to be provided, to ensure we only update a single
record and not every article in our database!
commands :create, update: :by_id
We’re making 2 important changes to both of our repo’s query methods:
Firstly, we’re adding aggregate(:categories)
, which will combine each
article result with all of its categories, using the associations we
declared back up in the relations.
Then we’re adding .as(Article)
, which will see the data from each of
the results being passed to the constructor of our Article
class.
Because this class is already configured to expect an array of
categories attributes, it will receive all the information it needs to
be initialized with valid data.
def [](id)
aggregate(:categories)
.by_id(id)
.as(Article)
.one!
end
def published
aggregate(:categories)
.published
.as(Article)
.to_a
end
end
end
We’re registering our two extra relations here. Again, this is something that would be taken care of for us when using rom-rb in a larger app, but it’s nice that rom-rb gives us all the setup hooks we need to make a little playground script like this work too.
config = ROM::Configuration.new(:sql, "sqlite::memory")
config.register_relation Relations::Articles
config.register_relation Relations::Categories
config.register_relation Relations::ArticlesCategories
container = ROM.container(config)
We’re adding the migrations for our two extra tables here, and using the
foreign_key
support from Sequel’s migration API for proper database
integrity.
container.gateways[:default].tap do |gateway|
migration = gateway.migration do
change do
create_table :articles do
primary_key :id
string :title, null: false
boolean :published, null: false, default: false
end
create_table :categories do
primary_key :id
string :name, null: false
end
create_table :articles_categories do
primary_key :id
foreign_key :article_id, :articles, null: false
foreign_key :category_id, :categories, null: false
end
end
end
migration.apply gateway.connection, :up
end
First, get a repo to use.
repo = Repositories::Articles.new(container)
Let’s just manually seed our database with some data. We need to make some articles, categories and their associations via the join table. Next week we’ll look at how we can do this nicely with rom-rb, but for now we need to sneak in a couple of lines of SQL.
repo.create(title: "Hello rom-rb", published: true)
connection = container.gateways[:default].connection
connection.execute "INSERT INTO categories (name) VALUES ('dry-rb')"
connection.execute "INSERT INTO categories (name) VALUES ('rom-rb')"
connection.execute "INSERT INTO articles_categories (article_id, category_id) VALUES (1, 1)"
connection.execute "INSERT INTO articles_categories (article_id, category_id) VALUES (1, 2)"
Now we should be able to see some results.
And look! Here we are: our article, wrapped up in a proper domain object, including both of its associated categories:
repo.published
# => [#<Article id=1 title="Hello rom-rb" published=true categories=[#<Category id=1 name="dry-rb">, #<Category id=2 name="rom-rb">]>]
puts "Published articles?"
published_articles = repo.published
puts published_articles.inspect
And with our repo’s new update
command, we should be able to modify that
article.
repo.update(published_articles.first.id, title: "rom-rb and dry-rb, sitting in a tree")
puts "Updated title?"
puts repo.published.first.title.inspect
We’ve seen a few more features of the upcoming rom-rb release, but our journey isn’t complete. We’ll come back for one more part, in which we’ll look at a command graph to create both articles and categories at the same time.
Until then, if you can clone this playground from GitHub and experiment:
git clone https://github.com/icelab/conversational-intro-to-rom-rb
cd conversational-intro-to-rom-rb
bundle
./intro.rb
Feel free to extend the playground, make your own changes and see how things work!
And if you want to learn more, check out the helpful documentation on the rom-rb website, and join the gitter chat if you have any questions!