Icelab

Conversational rom-rb, part 2: types, associations, and update commands

By Tim Riley10 Jun 2016

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.

Let’s get started

This is a continuation of our literate-style Ruby approach to exploring rom-rb. For the best reading experience, read the code and conversation side-by-side. Otherwise, you can still follow along below.

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"

# ## Define our app's persistence API
#
# 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:
      #
      # ```ruby
      # 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

# ## Define our app's types
#
# Type safety and shareable type definitions are an important pillar of any
# [dry-rb][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][dry-types] structs here gives us a [number of benefits][benefits],
# including:
#
# - Giving us a place to decorate the raw results from the database and
#   provide any special behaviour important for our app.
# - Making it easy for us to consume these objects in various contexts, since
#   we can be 100% confident in the type of data they contain - a dry-types
#   struct cannot be initialized with invalid attributes.
#
# 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
# datbase changes. These are also explicitly handled for us by rom-rb's
# dedicated query and commands objects, which we would never us accidentally.
#
# [dry-rb]: http://dry-rb.org/
# [dry-types]: http://dry-rb.org/gems/dry-types
# [benefits]: http://icelab.com.au/articles/inactive-records-the-value-objects-your-app-deserves/

# 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

# ## Define our app's repositories
#
# 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

# ## Initialize rom-rb
#
# 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)

# ## Prepare our database
#
# We're adding the migrations for our two extra tables here, and using the
# `foreign_key` support from [Sequel's migration API][fk] for proper database
# integrity.
#
# [fk]: http://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html#label-foreign_key
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

# ## Let's play!
#
# Alright, everything's in place. Let's try out these changes!


# 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:
#
# ```ruby
# 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")

# Has it worked? Yes!
#
# ```ruby
# repo.published.first.title
# # => "rom-rb and dry-rb, sitting in a tree"
# ```
puts "Updated title?"
puts repo.published.first.title.inspect

Next steps

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!


Read part 1 of this series: My past and future Ruby

Part 2: Inactive records: the value objects your app deserves

Part 3: Functional command objects in Ruby

Part 4: Effective Ruby dependency injection at scale

Part 5: Better code with an inversion of control container

Part 6: A change-positive Ruby web application architecture

Part 7: Put HTTP in its place with Roda

Part 8: A conversational introduction to rom-rb

Work with us, we’re good peopleGet in touch