Skip to main content

Comparing Rails' Active Record Pattern with Phoenix/Elixir/Ecto

Rails has a very well established Active Record pattern for dealing with the database. You have an Active Record model which maps to the database table, the schema of the model comes directly from the database schema and you place your model specific methods on the Active Record model. This file is also where you set your model relationships (e.g. has_many, has_one, belongs_to). Your instance of the model has all the methods built in.

In Ecto/Phoenix it's a little different. First of all, the database schema doesn't automatically map to the "model". In fact we don't really have models (as Elixir is a functional paradigm). What happens in one file in Rails, happens in essentially two (or more). You have a schema file (where you have to list out all the attributes and relationships). Using the schema file, your "instance" is essentially a data structure (with no methods on it). If you want to transform the data on your struct, you would use a context module (basically a collection of functions which take the struct in one format and return it in another).

In the project I am working on, we added another convention which was to have a contextual data layer which dealt with all the CRUD operations of our schema. I will probably do a follow up on this, but essentially using macros we created a pseudo inheritance pattern where all our data layer modules get default read and write actions (as well as return the data in tuples for error handling).

Anyways, an example is worth a thousand lines of text, so let's see how we would implement a basic blog data model in Rails vs Phoenix.

Let's start with 3 tables. Author, Article, and Comment.

One author has many articles, one article has many comments and a comment also has an author as well.

Rails
 
class Article < ApplicationRecord
  belongs_to :author
  has_many :comments
end

class Author < ApplicationRecord
  has_many :articles
  has_many :comments

  def full_name
    "#{first_name} #{last_name}"
  end
end

class Comment < ApplicationRecord
  belongs_to :articles
  belongs_to :comments
end
 

In Rails, this is pretty much all your models need. All the attributes of each will come directly from the database schema. If there is any functionality you want to add to the model you can also do so directly here. For example all instances of Author will have the full_name method automatically.

Elixir

defmodule Schema.Article do
  use Ecto.Schema
  import Ecto.Changeset

  alias Schema.Author
  alias Schema.Comment

  @permitted_fields [:title, :content]
  @required_fields [:title, :content]

  schema "article" do
    belongs_to(:author, Author)
    has_many(:comments, Comment)
    field(:title, :string)
    field(:content, :string)

    timestamps()
  end

  def changeset(article, params) do
    article
    |> cast(params, @permitted_fields)
    |> validate_required(@required_fields)
  end
end

defmodule Context.Articles do
  import Ecto.Query, warn: false
  alias Context.Repo

  alias Schema.Article

  def find(id) do
    Repo.get(Article, id)
  end

  ...
  #implement other data access methods as required
  ...
end

defmodule Schema.Author do
  use Ecto.Schema
  import Ecto.Changeset

  alias Schema.Article
  alias Schema.Comment

  @permitted_fields [:first_name, :last_name]
  @required_fields [:first_name, :last_name]

  schema "author" do
    has_many(:comments, Comment)
    has_many(:articles, Article)
    field(:first_name, :string)
    field(:last_name, :string)

    timestamps()
  end

  def changeset(author, params) do
    author
    |> cast(params, @permitted_fields)
    |> validate_required(@required_fields)
  end
end

defmodule Context.Authors do
  import Ecto.Query, warn: false
  alias Context.Repo

  alias Schema.Author

  def find(id) do
    Repo.get(Author, id)
  end

  ...
  #implement other data access methods as required
  ...

  # PS this is also an example of destructuring which I will cover later
  def full_name(%{first_name: first_name, last_name: last_name}) do
    "#{first_name} #{last_name}"
  end
end

defmodule Schema.Comment do
  use Ecto.Schema
  import Ecto.Changeset

  alias Schema.Article
  alias Schema.Author

  @permitted_fields [:text]
  @required_fields [:text]

  schema "comment" do
    belongs_to(:article, Article)
    belongs_to(:article, Author)
    field(:text, :string)

    timestamps()
  end

  def changeset(comment, params) do
    comment
    |> cast(params, @permitted_fields)
    |> validate_required(@required_fields)
  end
end

Now at first glance, this is obviously much more verbose. You can cut down on this verbosity using macros (which I will demonstrate later) but for the purposes of illustration, it's actually in some way more "obvious" in that there is less hidden functionality here. You also get a bit more flex ability (for one thing, you can access multiple repos quite easily in Phoenix which is very hard to do in Rails).

When you instantiate an Author, all you really get is a data struct.

For example

author = Context.Authors.find(1)

returns

%Author{first_name: "John", last_name: "Smith"})

If you want to get the author's full name you do so in the following manner

full_name = Context.Authors.full_name(author)

In Rails this would be

author = Author.find(1)
author.full_name

Calling author.full_name in Elixir would result in an error (as the struct only contains the first_name and last_name).

At first glance, this would make it seem like an obvious win for Rails. Look at how much code you need to do the same thing in Elixir! Look at all that "boilerplate"! I agree, some of the boiler plate could have been avoided (I do like the fact that in Rails the schema is automatically based on the database schema).

However, what if you created a new kind of struct/object that was based on a person (say an Admin as opposed to an Author) and you wanted to give that a full_name method, refactoring in Elixir is much easier. As the context is not directly tied to the model, you could create a User context which can take in any struct with a first_name and last_name and return the same result (there is no state to worry about in a functional context).

defmodule Context.Users do
  # PS this is also an example of destructuring which I will cover later
  def full_name(%{first_name: first_name, last_name: last_name}) do
    "#{first_name} #{last_name}"
  end
end

Then you would have

author = Context.Authors.find(1)
author_full_name = Context.Users.full_name(author)

admin = Context.Admins.find(1)
admin_full_name = Context.Users.full_name(admin)

What I found in Phoenix and Elixir is that you can move great chunks of code around quite easily compared to Rails because there is no state on the models/structs. Everything is a series of data transformations. The functionality is separate from the data.

Another thing that looks weird is having to define the changeset. The changeset is automatically defined for you in Rails. What we mainly use it for is to allow us to easily have different changesets for different situations (e.g. inserting vs updating). You might even have a multi phase scaffolding process which only requires some fields to be updated at a time (user sign up is one). In Rails, this is an all or nothing situation (e.g. you either have all the required fields at once, or the model is invalid).

In practice, you might want to have a Data context which deals solely with tying the schema to the database (e.g. the CRUD operations) and then put any extra functionality into another context. These data contexts can be based off macros to give an inheritance like functionality. I will cover this in the next article.

For myself, when I moved from Rails to Elixir, it made me realise how much was going on behind the scenes that I took for granted. Most of the time this isn't an issue, but it does lead to a little inflexibility (and this is always a trade off).

Comments

Popular posts from this blog

Master of my domain

Hi All, I just got myself a new domain ( http://www.skuunk.com ). The reason is that Blogspot.com is offering cheap domain via GoDaddy.com and I thought after having this nickname for nigh on 10 years it was time to buy the domain before someone else did (also I read somewhere that using blogspot.com in your domain is the equivalent of an aol.com or hotmail.com email address...shudder...). Of course I forgot that I would have to re-register my blog everywhere (which is taking ages) not to mention set up all my stats stuff again. *sigh*. It's a blogger's life... In any case, don't forget to bookmark the new address and to vote me up on Technorati !

Elixir - destructuring, function overloading and pattern matching

Why am I covering 3 Elixir topics at once? Well, perhaps it is to show you how the three are used together. Individually, any of these 3 are interesting, but combined, they provide you with a means of essentially getting rid of conditionals and spaghetti logic. Consider the following function. def greet_beatle(person) do case person.first_name do "John" -> "Hello John." "Paul" -> "Good day Paul." "George" -> "Georgie boy, how you doing?" "Ringo" -> "What a drummer!" _-> "You are not a Beatle, #{person.first_name}" end end Yeah, it basically works, but there is a big old case statement in there. If you wanted to do something more as well depending on the person, you could easily end up with some spaghetti logic. Let's see how we can simplify this a little. def greet_beatle(%{first_name: first_name}) do case first_name d

Speeding up RSpec

So today I have been looking into getting our enormous battery of tests to run faster. I have yet to find anything that works for Cucumber, but I did find an interesting way to speed up RSpec which is detailed here. https://makandracards.com/makandra/950-speed-up-rspec-by-deferring-garbage-collection Basically, it seems that by not collecting garbage too frequently, you can make your tests run much faster (at the expense of memory management of course). We observed a 30% reduction in the time it takes to run an RSpec test suite. I did try to implement this on Cucumber, however because we need to store much more in memory to set up and tear down our objects, it meant that I kept running out of memory when I wasn't using the default Garbage Collection and the tests took even longer (so, buyer beware). I suppose if you had a small set of features though you might see some benefit.