Ecto vs ActiveRecord

In this post, I am going to take a look at Ecto, a data mapping and database query library designed to provide seamless integration between Elixir code and database operations and compare it to Ruby on Rail's ActiveRecord.

Ecto and ActiveRecord are both popular data access libraries in their communities, but they have different design philosophies and approaches to handling database operations.

Design Pattern

Ecto follows the Repository design pattern, where all data mapping and query operations are centralized through a repository.

It is best to take a look at a simple example to illustrate this:

user = Repo.get_by(MyApp.User, name: "Han")
changes = Ecto.Changeset.change(user, name: "Sam")
Repo.update!(changes)

As you can see, in Ecto data operations are orchestrated through the Repo Module which provides a structured and centralized way to interact with the database. This separation of concerns between data access and domain logic contributes to a distinct programming paradigm in Ecto compared to ActiveRecord, which follows the Active Record design pattern, where data access and persistence logic are directly tied to the model classes.

In other words, all operations in Active Record are directly performed on the model class itself. This includes queries, updates, and other database actions. For example, fetching and updating a User record in ActiveRecord would be done as follows:

user = User.find_by(name: "Han")
user.update!(name: "Sam")

Explicitness of Operations

Ecto emphasizes explicitness in database operations, making queries and data manipulation actions clear and easy to reason about in the code.

In contrast, ActiveRecord often relies on "magic" methods and conventions to handle database operations automatically. While this can simplify development in some cases, it may introduce complexity and make it harder to understand the underlying database interactions.

Let's explore another code example that highlights this difference between the two. Here's how you would insert a new user record into the database using Ecto:


# User Schema Definition in Elixir (Ecto):
defmodule User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    field :age, :string

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :age])
    |> validate_required([:name, :age])
  end
end

# Creating a new user record using Ecto
changeset = User.changeset(%User{}, %{name: "Alice", age: 30})

case Repo.insert(changeset) do
  {:ok, user} ->
    IO.puts("User inserted: #{inspect(user)}")
  {:error, changeset} ->
    IO.puts("Error inserting user: #{inspect(changeset.errors)}")
end

In this Elixir code snippet, the process of creating a new user record involves explicitly creating a changeset, validating the data, and then inserting the record into the database using Repo.insert(). The code clearly outlines the steps involved in inserting a new user record.

Let's see how you would insert a new user record into the database using ActiveRecord:

# Example of User model defined in ActiveRecord
class User < ApplicationRecord
  validates :name, presence: true
  validates :age, presence: true
end

# Creating a new user record using ActiveRecord
user = User.new(name: "Alice", age: 30)
if user.save
  puts "User inserted: #{user.inspect}"
else
  puts "Error inserting user: #{user.errors.full_messages}"
end

In Ruby, the process of creating and saving a new user record is simplified using ActiveRecord's 'magic' methods. The User.new method initializes a new user object, and user.save handles the insertion into the database, abstracting away some of the explicit steps involved.

The above example also highlights another very important key difference between Ecto and Active Record's design philosophy.

Functional vs Object Oriented Paradigm

Ecto leverages Elixir's functional programming capabilities, with changesets highlighting the functional approach to data manipulation. As you have already seen changesets are built through a series of function calls, ensuring data validity before persistence.

To further demonstrate the functional step-by-step approach of Ecto changesets, we can enhance the changeset/2 function above to include additional steps such as data transformation and custom validations:

# User Schema Definition in Elixir (Ecto)
defmodule User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :age, :integer

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :age])
    |> validate_required([:name, :age])
    |> transform_age_to_integer
    |> validate_age_range
  end

  defp transform_age_to_integer(changeset) do
    case get_field(changeset, :age) do
      nil -> changeset
      age -> put_change(changeset, :age, String.to_integer(age))
    end
  end

  defp validate_age_range(changeset) do
    case get_field(changeset, :age) do
      nil -> changeset
      age when age < 18 or age > 100 ->
        add_error(changeset, :age, "Age must be between 18 and 100")
      _ -> changeset
    end
  end
end

ActiveRecord, on the other hand, is more object-oriented, treating database records as objects with built-in methods for validating data.

To mirror the functionality demonstrated in the Ecto example above, we can enhance the User model in ActiveRecord to include custom validations for the age range and data transformation for the age attribute.

# User Model Definition in Ruby (ActiveRecord)
class User < ApplicationRecord
  validates :name, presence: true
  validates :age, presence: true
  validate :age_range_validation

  before_validation :transform_age_to_integer

  private

  def transform_age_to_integer
    self.age = age.to_i if age.present?
  end

  def age_range_validation
    if age.present? && (age < 18 || age > 100)
      errors.add(:age, "Age must be between 18 and 100")
    end
  end
end

Querying the Database

Ecto provides a query DSL (Domain-Specific Language) that closely resembles SQL but is integrated into Elixir. This DSL allows developers to write complex queries with advanced filters, joins, and ordering in a way that feels natural within the Elixir ecosystem.

# Ecto Query Example in Elixir (Ecto)
query = from u in User,
        where: u.age > 18,
        select: u.name
Repo.all(query)

# Raw SQL Equivalent to Ecto Query above
SELECT name
FROM users
WHERE age > 18;

In the example above, the from macro is used to construct a query that selects the names of users with an age greater than 18. As you can see, the syntax of Ecto queries closely resembles raw SQL statements, making it intuitive for developers familiar with writing SQL queries.

Here is the same database operation written in Ruby using Active Record:

# ActiveRecord Query Example in Ruby (ActiveRecord)
User.where("age > ?", 18).pluck(:name)

In the Ruby example above, the where method is used to filter users based on age greater than 18, and the pluck method is used to select only the names of the filtered users. ActiveRecord queries in Ruby use a different syntax compared to raw SQL statements, providing a more abstract and object-oriented approach to querying the database.

This was a short post on Ecto, the popular data mapping and database query library from the Elixir community comparing it to Ruby's equivalent Active Record.
In a future post, I am going to dig much deeper into the topic of Ecto.

Thank you for reading.