In Progress
Unit 1, Lesson 1
In Progress

Writing changes with rom-rb

In the previous episode in this series, we were introduced to the ROM database persistence library. We saw how ROM uses Repositories and Relations to handle database queries. Today, guest chef Tim Riley continues the ROM tour with an explanation of how ROM uses the concept of Changesets to manage writes to the database. Enjoy!

Video transcript & code


In our first look at rom-rb, we got to know its general structure and philosophy, then built some repositories and relations so we could read from our database.

This time around, we'll close the cycle, and write some data back.


To do this, we'll work with changesets, a dedicated abstraction for making changes to our database.


So let's revisit the repository we built last time:


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def latest
    articles
      .combine(:author)
      .ordered_by_recency
      .to_a
  end
end

Oh yes, that's right, we were building everyone's favorite example app, the good old blog in 15 minutes.

This was our article repository, and we built this #latest method, using the articles relation to fetch a list of our most recent articles.


OK, so let's go ahead and add support for creating an article. We'll add a #create method, accepting a hash of attributes.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
  end
end

We'll work with the articles relation again here, this time building a "create" changeset with those attributes.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs)
  end
end

To get a good sense of changesets as an abstraction, let's pause at this point and see just what this method returns.


So we'll get an instance of our repository again, call #create...


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs)
  end
end

repo = ArticleRepo.new(rom)

repo.create(title: "Together breakfast", published_at: Time.now)

And in return we get a changeset object.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs)
  end
end

repo = ArticleRepo.new(rom)

repo.create(title: "Together breakfast", published_at: Time.now)
# => #<ROM::Changeset::Create relation=ROM::Relation::Name(articles) data={:title=>"Together breakfast", :published_at=>2018-10-12 23:26:15 +1100}>

It's an instance of ROM::Changeset::Create,


we can see it's associated with our articles relation,


and it carries the data we've just passed.


We can also call #to_h on it, and we'll see a hash with that same data.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs)
  end
end

repo = ArticleRepo.new(rom)

repo.create(title: "Together breakfast", published_at: Time.now).to_h
# => {:title=>"Together breakfast", :published_at=>2018-10-12 23:29:42 +1100}

This is how rom changesets work:

  • they connect with a relation,
  • they carry the data we've passed to them,
  • and they convert to a hash.

This hash is what is ultimately passed through a rom command and written to the database.


Now, to achieve that, we can call #commit on the changeset.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end
end

repo = ArticleRepo.new(rom)

repo.create(title: "Together breakfast", published_at: Time.now)

And now we can see the output of this committed changeset: a newly created article struct.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end
end

repo = ArticleRepo.new(rom)

repo.create(title: "Together breakfast", published_at: Time.now)
# => #<Entities::Article id=1 title="Together breakfast" author_id=nil published_at=2018-10-12 21:39:36 +1100>

This article has an id, so we can be sure it represents a new row in our articles table.


This article is also an instance of the custom Article class we made in our previous episode, because this repository is still using our struct_namespace.

This means we get the same kinds of objects back from this repository whether we're reading from the database or writing to it, which is handy.


So now we've created a record, let's go ahead and update it.

For this we'll define an #update method, taking an id parameter before the hash of attributes.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end

  def update(id, attrs)
  end
end

Before we build the changeset this time, we want to apply a restriction to the articles relation, limiting it so the change will apply only to the article with the matching id.

To do this we call the #by_pk method that every SQL relation has by default.

"pk" here is a database term, meaning "primary key". rom defines this method for us by determining which of the columns is our table's primary key.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end

  def update(id, attrs)
    articles.by_pk(id)
  end
end

From here we can build an "update" changeset with the new attributes, and commit it to update the record.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end

  def update(id, attrs)
    articles.by_pk(id).changeset(:update, attrs).commit
  end
end

Now, we can create an article...


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end

  def update(id, attrs)
    articles.by_pk(id).changeset(:update, attrs).commit
  end
end

repo = ArticleRepo.new(rom)

article = repo.create(title: "Together breakfast", published_at: Time.now)
# => #<Entities::Article id=1 title="Together breakfast" author_id=nil published_at=2018-10-12 22:33:23 +1100>

... and then update it too.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end

  def update(id, attrs)
    articles.by_pk(id).changeset(:update, attrs).commit
  end
end

repo = ArticleRepo.new(rom)

article = repo.create(title: "Together breakfast", published_at: Time.now)
# => #<Entities::Article id=1 title="Together breakfast" author_id=nil published_at=2018-10-12 22:33:23 +1100>

updated_article = repo.update(article.id, title: "Together breakfast: I made you to bring us together!")
# => #<Entities::Article id=1 title="Together breakfast: I made you to bring us together!" author_id=nil published_at=2018-10-12 22:33:23 +1100>

So those are the basics.

Now let's dig a little deeper and see just why this approach is worthwhile, why it's worth having these separate objects representing each change we make to our database.


One of the things that changesets offer us is a dedicated place to process and transform our data before it's written to the database.


Let's say we want all our articles to have a slug, so we can offer friendly URLs.

We can make it so a changeset is responsible for generating this slug.


If we're going to provide this custom behaviour, we need to make our own changeset class.

This one will inherit from ROM::Changeset::Create:


class CreateArticle < ROM::Changeset::Create
end

Then inside we can open up a mapping block to apply a transformation to the input attributes:


class CreateArticle < ROM::Changeset::Create
  map do |attrs|
  end
end

Inside this block we can do anything we like, as long as we return another hash of attributes.


So here, let's generate a slug from the title and merge it into a new attributes hash.


class CreateArticle < ROM::Changeset::Create
  map do |attrs|
    attrs.merge(
      slug: attrs[:title].downcase.gsub(/\s/, "-"),
    )
  end
end

Now we can go back to our repository...


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(:create, attrs).commit
  end
end

... and specify that the changeset should use this custom class. .


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(CreateArticle, attrs).commit
  end
end

Now when we create the article, we can see the slug is generated for us.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(CreateArticle, attrs).commit
  end
end

repo = ArticleRepo.new(rom)

repo.create(title: "Together breakfast", published_at: Time.now)
# => #<Entities::Article id=1 title="Together breakfast" slug="together-breakfast" author_id=nil published_at=2018-10-12 23:09:25 +1100>

Now this might seem like a lot of song and dance simply for generating article slugs.

But consider, in a larger app, we may have multiple types of records all requiring similar slugging behaviour.

Since we use changesets to handle our database updates, we could extract this slugging logic into a module, include it in all the relevant changeset classes, and hey presto, we have slugging everywhere.

In other frameworks, this kind of behavior might be buried in yet another callback attached to the main Article model, or worse still, controlled by some hard-to-manage plugin. But in this case, it gets its own class, which we can control. And we can also decide when is the right time to use it.


Let's look at one last feature of changesets.

They can help us associate records before we write them to the database.


In this app, our articles all have an author_id column, and in our last episode we established a "belongs to" association between an article and its author.


module Relations
  class Articles < ROM::Relation[:sql]
    schema :articles, infer: true do
      associations do
        belongs_to :author
      end
    end
  end
end

This is enough for us to to create both a new article and a new associated author at the same time.


Let's modify our #create method to demonstrate this.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    articles.changeset(CreateArticle, attrs).commit
  end
end

Firstly we want to open a database transaction, to ensure consistency as we write to multiple database tables.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    transaction do
      articles.changeset(CreateArticle, attrs).commit
    end
  end
end

Now we can create an author based on some nested attributes:


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    transaction do
      author = authors.changeset(:create, attrs[:author]).commit

      articles.changeset(CreateArticle, attrs).commit
    end
  end
end

Then we can associate that author to our "create article" changeset, just before we commit it.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    transaction do
      author = authors.changeset(:create, attrs[:author]).commit

      articles.changeset(CreateArticle, attrs).associate(author).commit
    end
  end
end

Notice we aren't doing anything specific here to name the association we want to use. rom-rb can infer this for us based on this author struct that we're passing to associate.


Now when we create our article and provide some associated author details, we'll see the result has an author_id present, indicating that the author was created and then associated to the article.


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    transaction do
      author = authors.changeset(:create, attrs[:author]).commit

      articles.changeset(CreateArticle, attrs).associate(author).commit
    end
  end
end

repo = ArticleRepo.new(rom)

article = repo.create(
  title: "Together breakfast",
  published_at: Time.now,
  author: {name: "Rebecca Sugar"},
)
# => #<Entities::Article id=1 title="Together breakfast" slug="together-breakfast" author_id=1 published_at=2018-10-16 21:28:00 +1100>

Let's double check that. If we call that #latest method we defined earlier, we'll see this article with the author struct included in full:


class ArticleRepo < ROM::Repository
  struct_namespace Entities

  def create(attrs)
    transaction do
      author = authors.changeset(:create, attrs[:author]).commit

      articles.changeset(CreateArticle, attrs).associate(author).commit
    end
  end
end

repo = ArticleRepo.new(rom)

article = repo.create(
  title: "Together breakfast",
  published_at: Time.now,
  author: {name: "Jane Doe"},
)
# => #<Entities::Article id=1 title="Together breakfast" slug="together-breakfast" author_id=1 published_at=2018-10-16 21:28:00 +1100>

repo.latest
# => [#<Entities::Article id=1 title="Together breakfast" slug="together-breakfast" author_id=1 published_at=2018-10-16 21:28:00 +1100 author=#<Entities::Author id=1 name="Rebecca Sugar">>]

So that's a look at how changesets work within rom.


# Changesets

Like everything so far, these have been simple examples. There's plenty more you can learn if you check out the documentation.


The important thing to take away here is that rom considers reading from and writing to the database as two distinct activities, and it offers dedicated abstractions for each.


# Changesets

- In rom-rb, reading and writing are distinct activities

Making a change to the database with rom needn't be some transient method call that we can't hook into.

Instead, with changesets, we get whole objects dedicated to representing that change, objects which we can inspect, pass around, and even customize.


# Changesets

- In rom-rb, reading and writing are distinct activities
- We have whole objects representing the changes we make

And changesets are powerful objects at that, because they give us the opportunity to mediate between our application's domain layer and the persistence layer.

They can take data in whatever shape is most appropriate for the application, and then do the work necessary to make that data acceptable to the database.

This is another way we maintain a clean separation between our domain layer and persistence layer.


# Changesets

- In rom-rb, reading and writing are distinct activities
- Changesets are _dedicated_ to making changes, nothing more
- An opportunity to mediate between domain and persistence layers

And as application authors, since we define our own persistence API, through our repositories, we're not limited to writing to the database in just one way only.

If for whatever reason our application is best served with differing behaviours for writing to the database, well, we can offer those up through as many distinct methods as we need.

Because we're not just renting someone else's persistence API, we own it.


# Changesets

- In rom-rb, reading and writing are distinct activities
- We have whole objects representing the changes we make
- An opportunity to mediate between domain and persistence layers
- Write to the database in as many different ways as our app requires

Now we've traveled the full cycle of reads and writes, and should have a good feel for how rom-rb works.

We'll be back for one more episode, where we'll take the gloves off and see just what rom can really do.

This one's going to be good.

Until then, happy hacking!

Responses