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
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
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).
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