When dealing with databases and information management systems, it is common to create CRUD (Create, Read, Update, Delete) operations in programming. However, Elixir, a robust and highly concurrent functional programming language, offers a powerful feature that can significantly simplify the process of creating CRUDs. In this article, I will share my experience in creating generalized CRUDs using Elixir macros.

Understanding Macros

In Elixir, macros are special constructs that allow code generation at compile-time. They enable writing code that manipulates and transforms other code snippets. Macros are evaluated during the compilation process, generating code that will be executed later.

Macros are defined using defmacro and take the code to be transformed as an argument. Inside a macro, you can use Elixir’s functions and control structures to manipulate the input code and generate output code.

Macros are useful for automating repetitive tasks, reducing code redundancy, and providing powerful abstractions. They are widely used in Elixir to create Domain-Specific Languages (DSLs), facilitate the implementation of design patterns, and simplify the development of frameworks and libraries.

However, the use of macros must be done with care. It is necessary to understand how they work and apply best practices to ensure that the generated code is readable, maintainable, and efficient.

Defining CRUD Operations

The basic CRUD operations are: Create, Read, Update, and Delete. Based on these operations, we can start structuring our macro.

Creating the Macro

Now that we have a basic understanding of macros and know which operations to perform in our CRUD, we can proceed with creating the macro. Using defmacro, we can generate the necessary functions for each CRUD operation, taking into account the specifics of our domain. We can customize the logic of each function, such as data validation, error handling, and other specific operations.

Implementing the Macro in Practice

In a non-macro-based CRUD, we would have multiple contexts, each with four (or more) functions, one for each CRUD operation. With macros, we can move the generic functions inside the macro.

defmodule Application.Api do
  @callback create(map) :: {:ok, struct} | {:error, changeset}

  defmacro __using__([schema: schema] = _opts) do
    quote do
      @behaviour Api

      import Ecto.Query

      alias Application.Repo

      schema_name =
        unquote(schema)
        |> Atom.to_string()
        |> String.replace_prefix("Elixir.Application.", "")

      @doc "Creates a record of `#{schema_name}`"
      @spec create(map) :: {:ok, unquote(schema).t()} | {:error, Ecto.Queryable.t()}
      def create(attrs) do
        %unquote(schema){}
        |> unquote(schema).changeset(attrs)
        |> Repo.insert()
      end

      defoverridable Api
    end
  end
end

First, outside the macro, we define the @callback create. This definition specifies the function signature for create, expecting it to be implemented by modules that use Application.Api. The create function expects to receive a map as an argument and can return {:ok, struct} on success or {:error, changeset} on failure.

The __using__ macro takes an optional schema argument, which represents the schema to be used within the module context that includes Application.Api. Inside the quote construct, we implement the functions specified by the callbacks created outside the macro. We can add any necessary imports or aliases to the implementations.

We can obtain the name of the schema being passed as an argument to the macro using

unquote. We assign it to a variable schema_name.

The implementation of the macro within a module is straightforward. We pass the Application.Api macro to use and then specify the schema we want to use for a particular module.

defmodule Application.Api.Users do
  use Application.Api, schema: Application.User
end

A complete CRUD includes:

Create:

@doc "Creates a record of `#{schema_name}`"
@spec create(map) :: {:ok, unquote(schema).t()} | {:error, Ecto.Queryable.t()}
def create(attrs) do
  %unquote(schema){}
  |> unquote(schema).changeset(attrs)
  |> Repo.insert()
end

Read:

@doc "Returns all records of `#{schema_name}` that match the provided query"
@spec all(Ecto.Queryable.t()) :: [unquote(schema).t()]
def all(query), do: Repo.all(query)

@doc "Returns the first record of `#{schema_name}` that matches the provided query"
@spec one(Ecto.Queryable.t()) :: unquote(schema).t()
def one(query, opts \\ []), do: Repo.one(query, opts)

Update:

@doc "Updates a record of `#{schema_name}`"
@spec update(unquote(schema).t(), map) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()}
def update(%unquote(schema){} = data, attrs) do
  data
  |> unquote(schema).changeset(attrs)
  |> Repo.update()
end

Delete:

# delete/1 and undelete/1 are available when the schema has `deleted_at` field
if :deleted_at in Map.keys(%unquote(schema){}) do
  @doc "Deletes a record of `#{schema_name}`"
  @spec delete(unquote(schema).t()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()}
  def delete(%unquote(schema){} = data) do
    attrs = %{deleted_at: DateTime.utc_now()}

    data
    |> Ecto.Changeset.cast(attrs, [:deleted_at])
    |> Repo.update()
  end

  @doc "Undeletes a record of `#{schema_name}`"
  @spec undelete(unquote(schema).t()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()}
  def undelete(%unquote(schema){} = data) do
    attrs = %{deleted_at: nil}

    data
    |> Ecto.Changeset.cast(attrs, [:deleted_at])
    |> Repo.update()
  end
end

For the DELETE operation, “soft delete” functions were created along with a rule that implements the delete function in the module only if the schema passed as an argument has a field named deleted_at. In “soft delete,” the data is not permanently deleted from the database; it is only marked as deleted, but it can be recovered at any time using the undelete function.

Function Overriding:

One crucial detail: at the end of the macro, we have defoverridable Api. defoverridable indicates that the functions defined by @callback can be overridden by modules that use Application.Api. This means that we can make changes to the behavior of each implementation without affecting all modules, as follows:

defmodule Application.Api.Users do
  use Application.Api, schema: Application.User

  alias Application.User

  def create(attrs) do
    user = %__MODULE__{}
    |> User.changeset(attrs)
    |> Repo.insert()

    case user do
      nil -> {:error, "User not found."}
      user -> {:ok, user}
    end
  end
end

In this way, the create function defined within the Application.Api.Users module will override the create function defined by the macro.

Conclusion

In this article, we explored how to create a generic CRUD using macros in Elixir. By defining a custom macro, we were able to generate the necessary functions for each CRUD operation, taking into account the particularities of the domain in question. We provided examples of implementations for creating, reading, updating, and deleting records.

It is important to keep in mind that using macros requires a solid understanding of how they work and the application of good programming practices. We must ensure that the generated code is readable, easy to maintain, and efficient. Additionally, it is recommended to use the defoverridable directive to allow customization of the generated functions if it is necessary to modify the default behavior.

By adopting the creation of a CRUD with macros, we can increase development productivity in Elixir, reduce repetitive code, and promote better organization and code reuse. This approach offers an elegant way to handle common CRUD tasks, allowing developers to focus on more complex and specific aspects of the project.