EctoCooler

Ecto Cooler Logo

Eliminate boilerplate involved in defining basic CRUD functions in a Phoenix context or Elixir module.

About

When using Context modules in a Phoenix application, there's a general need to define standard CRUD functions for a given Ecto.Schema. Phoenix context generators can even do this automatically. However, you'll soon notice that there's quite a lot of code involved in CRUD access within your contexts.

This can become problematic for several reasons:

In short, at best, this code is redundant and at worst is a deviant entanglement of modified conventions. All of which amounts to a more-painful development experience. EctoCooler was created to ease this pain.

Features

Installation

This package is available in Hex, the package can be installed by adding ecto_cooler to your list of dependencies in mix.exs:

    def deps do
      [
        # Use the latest 2.x release
        {:ecto_cooler, "~> 2.0"}
      ]
    end

Configuration

The bare minimum app config is an app_name and an app_slug. These are only necessary if you are using generators. Otherwise there is no configuration necessary.

config :ecto_cooler,
  app_name: "MyApp",
  app_slug: :my_app

Configuration is only necessary if you intend to use the generators for Phoenix applications. The full config is as follows:

config :ecto_cooler,
    app_name: "MyApp",
    app_slug: :my_app,
    generators: [binary_id: true],
    migration_dir: "priv/repo/migrations",
    repo_dir: "lib/my_app/repo",
    repo_namespace: "Repo",
    schema_dir: "lib/my_app/schema",
    schema_namespace: "Schema",
    resources: [suffix: true]

NOTE: If binary_id is configured in your Phoenix configuration and you have app_slug defined in your ecto_cooler configuration, you don't need to specify the generators: [binary_id: true] in the ecto_cooler config since it will be picked up from the Phoenix configuration.

NOTE: The resources: [suffix: true] option controls whether generated repo modules use suffixed function names (e.g., get_post/2 instead of get/2).

Usage

Basic usage - generate all EctoCooler functions

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post)
  end
end

This generates all the functions EctoCooler has to offer:

If you want the functions to be namespaced, you can use the suffix: true option.

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post, suffix: true)
  end
end

This generates all the functions EctoCooler with a suffix:

Explicit usage - generate only given functions

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post, only: [:create, :delete!])
  end
end

This generates only the given functions:

Exclusive usage - generate all but the given functions

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post, except: [:create, :delete!])
  end
end

This generates all the functions excluding the given functions:

Alias :read - generate data access functions

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post, :read)
  end
end

This generates all the functions necessary for reading data:

Alias :read_write - generate data access and manipulation functions, excluding delete

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post, :read_write)
  end
end

This generates all the functions except delete/1 and delete!/1:

Alias :write - generate data mutation functions

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post, :write)
  end
end

This generates all the functions that modify data, excluding read-only functions:

Alias :delete - generate only delete helpers

defmodule MyApp.Repo.Posts do
  alias MyApp.Repo
  alias MyApp.Schema.Post

  use EctoCooler

  using_repo(Repo) do
    resource(Post, :delete)
  end
end

Generated functions:

Resource functions

The general idea of the generated resource functions is to abstract away the Ecto.Repo and Ecto.Schema parts of data access with Ecto and provide an API to the context that feels natural and clear to the caller.

The following examples will all assume a repo named Posts and a schema named Post.

Posts.all

Fetches a list of all %Post{} entries from the data store. Note: EctoCooler will pluralize this function name using Drops.Inflector

Accepts the following keyword options:

iex> Posts.all()
[%Post{id: 1}]

iex> Posts.all(preloads: [:comments])
[%Post{id: 1, comments: [%Comment{}]}]

iex> Posts.all(order_by: [desc: :id])
[%Post{id: 2}, %Post{id: 1}]

iex> Posts.all(preloads: [:comments], order_by: [desc: :id])
[
  %Post{
    id: 2,
    comments: [%Comment{}]
  },
  %Post{
    id: 1,
    comments: [%Comment{}]
  }
]

iex> Posts.all(where: [category: "Testing"])
[
  %Post{
    id: 42,
    category: "Testing"
  },
  %Post{
    id: 99,
    category: "Testing"
  }
]

iex> Posts.all(where: [category: "Testing"], order_by: [asc: :id], preloads: [:comments])
[
  %Post{
    id: 42,
    category: "Testing",
    comments: [%Comment{}]
  },
  %Post{
    id: 99,
    category: "Testing",
    comments: [%Comment{}]
  }
]

Posts.change

Creates a changeset from an existing %Post{} struct with the given changes. Takes two arguments: the struct and a map or keyword list of changes.

iex> Posts.change(%Post{title: "Old Title"}, %{title: "New Title"})
#Ecto.Changeset<
  action: nil,
  changes: %{title: "New Title"},
  errors: [],
  data: #Post<>,
  valid?: true
>

iex> Posts.change(%Post{}, %{title: "Example Post"})
#Ecto.Changeset<
  action: nil,
  changes: %{title: "Example Post"},
  errors: [],
  data: #Post<>,
  valid?: true
>

Posts.changeset

Creates a blank changeset for %Post{}. Takes no arguments.

iex> Posts.changeset()
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [],
  data: #Post<>,
  valid?: true
>

Posts.create

Inserts a %Post{} with the given attributes in the data store, returning an :ok/:error tuple.

iex> Posts.create(%{title: "Example Post"})
{:ok, %Post{id: 123, title: "Example Post"}}

iex> Posts.create(%{invalid: "invalid"})
{:error, %Ecto.Changeset{}}

Posts.create!

Inserts a %Post{} with the given attributes in the data store, returning a %Post{} or raises Ecto.InvalidChangesetError.

iex> Posts.create!(%{title: "Example Post"})
%Post{id: 123, title: "Example Post"}

iex> Posts.create!(%{invalid: "invalid"})
** (Ecto.InvalidChangesetError)

Posts.delete

Deletes a given %Post{} from the data store, returning an :ok/:error tuple.

iex> Posts.delete(%Post{id: 1})
{:ok, %Post{id: 1}}

iex> Posts.delete(%Post{id: 999})
{:error, %Ecto.Changeset{}}

Posts.delete!

Deletes a given %Post{} from the data store, returning the deleted %Post{}, or raises Ecto.StaleEntryError.

iex> Posts.delete!(%Post{id: 1})
%Post{id: 1}

iex> Posts.delete!(%Post{id: 999})
** (Ecto.StaleEntryError)

Posts.get

Fetches a single %Post{} from the data store where the primary key matches the given id, returns a %Post{} or nil.

Accepts an optional keyword list with preloads.

iex> Posts.get(1)
%Post{id: 1}

iex> Posts.get(999)
nil

iex> Posts.get(1, preloads: [:comments])
%Post{
    id: 1,
    comments: [%Comment{}]
}

Posts.get!

Fetches a single %Post{} from the data store where the primary key matches the given id, returns a %Post{} or raises Ecto.NoResultsError.

Accepts an optional keyword list with preloads.

iex> Posts.get!(1)
%Post{id: 1}

iex> Posts.get!(999)
** (Ecto.NoResultsError)

iex> Posts.get!(1, preloads: [:comments])
%Post{
    id: 1,
    comments: [%Comment{}]
}

Posts.get_by

Fetches a single %Post{} from the data store where the attributes match the given values. Returns nil if no record is found.

Accepts an optional second argument keyword list with preloads.

iex> Posts.get_by(%{title: "Example Title"})
%Post{title: "Example Title"}

iex> Posts.get_by(%{title: "Doesn&#39;t Exist"})
nil

iex> Posts.get_by(%{title: "Example Title"}, preloads: [:comments])
%Post{title: "Example Title", comments: [%Comment{}]}

Posts.get_by!

Fetches a single %Post{} from the data store where the attributes match the given values. Raises an Ecto.NoResultsError if the record does not exist.

Accepts an optional second argument keyword list with preloads.

iex> Posts.get_by!(%{title: "Example Title"})
%Post{title: "Example Title"}

iex> Posts.get_by!(%{title: "Doesn&#39;t Exist"})
** (Ecto.NoResultsError)

iex> Posts.get_by!(%{title: "Example Title"}, preloads: [:comments])
%Post{title: "Example Title", comments: [%Comment{}]}

Posts.update

Updates a given %Post{} with the given attributes, returning an {:ok, %Post{}} or {:error, Ecto.Changeset} tuple.

iex> Posts.update(%Post{id: 1}, %{title: "Updated Title"})
{:ok, %Post{id: 1, title: "Updated Title"}}

iex> Posts.update(%Post{id: 1}, %{invalid: "invalid"})
{:error, %Ecto.Changeset{}}

Posts.update!

Updates a given %Post{} with the given attributes, returning a %Post{} or raising Ecto.InvalidChangesetError.

iex> Posts.update!(%Post{id: 1}, %{title: "Updated Title"})
%Post{id: 1, title: "Updated Title"}

iex> Posts.update!(%Post{id: 1}, %{invalid: "invalid"})
** (Ecto.InvalidChangesetError)

Generators

Generators are EctoCooler replacements for Phoenix Context and Schema generators. They create files that follow EctoCooler conventions out of the box.

All generators require the app_name and app_slug configuration options to be set. See Configuration for details.

mix ectc.gen.repo

Generates a repo context module, a schema module, and a migration file.

mix ectc.gen.repo [repo_name] [schema_name] [table_name] [attributes...]

Example:

mix ectc.gen.repo Posts Post posts title:string author:string

This creates three files:

lib/my_app/repo/posts.ex — Repo context module:

defmodule MyApp.Repo.Posts do
  use EctoCooler

  import Ecto.Query, warn: false

  alias MyApp.Repo
  alias MyApp.Schema.Post

  using_repo(Repo) do
    resource(Post)
  end
end

lib/my_app/schema/post.ex — Ecto schema:

defmodule MyApp.Schema.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :author, :string

    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :author])
    |> validate_required([:title, :author])
  end
end

priv/repo/migrations/TIMESTAMP_create_posts.exs — Ecto migration:

defmodule MyApp.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def change do
    create table(:posts) do
      add :title, :text, null: false
      add :author, :text, null: false

      timestamps()
    end
  end
end

mix ectc.gen.schema

Generates a schema module and an accompanying migration file.

mix ectc.gen.schema [schema_name] [table_name] [attributes...]

Example:

mix ectc.gen.schema Post posts title:string author:string

This creates two files:

mix ectc.gen.migration

Generates only a migration file.

mix ectc.gen.migration [schema_name] [table_name] [attributes...]

Example:

mix ectc.gen.migration Post posts title:string author:string

This creates:

mix ecto_cooler.resources

Lists all generated resource functions for a given context module. Useful for verifying which functions EctoCooler has generated.

mix ecto_cooler.resources [module_name]

Example:

mix ecto_cooler.resources MyApp.Repo.Posts

Output:

Within the context MyApp.Repo.Posts, the following resource functions have been generated:

Post using the repo Repo:
- MyApp.Repo.Posts.all/1
- MyApp.Repo.Posts.change/2
- MyApp.Repo.Posts.changeset/0
- MyApp.Repo.Posts.create/1
- MyApp.Repo.Posts.create!/1
- MyApp.Repo.Posts.delete/1
- MyApp.Repo.Posts.delete!/1
- MyApp.Repo.Posts.get/2
- MyApp.Repo.Posts.get!/2
- MyApp.Repo.Posts.get_by/2
- MyApp.Repo.Posts.get_by!/2
- MyApp.Repo.Posts.update/2
- MyApp.Repo.Posts.update!/2

Attribute format

All generators accept attributes in the format field:type. Available formats:

Format Example Description
field:typetitle:string Standard field with null: false in migration, included in validate_required
field:type:nullbio:string:null Nullable field — omits null: false in migration, excluded from validate_required
field:references:tableuser_id:references:users Foreign key reference

Supported type aliases:

Alias Ecto Type
string:text
int:integer
bool:boolean
json:map

All other types (e.g., integer, float, date, utc_datetime) are passed through as-is.

Note: The string type maps to Postgres :text (not :string/varchar as in Phoenix generators). This means all string fields use unbounded text columns by default.

Caveats

This package is meant to bring a lot of "out-of-the-box" basic functionality for working with Ecto schemas/queries and reducing boilerplate. Some contexts may never need to have anything more than EctoCooler while others will accumulate many custom queries/commands. EctoCooler is a lightweight foundation which can be built upon or worked around completely. Be wary of your use of all().

Contribution

Bug reports

If you discover any bugs, feel free to create an issue on GitHub. Please add as much information as possible to help in fixing the potential bug. You are also encouraged to help even more by forking and sending us a pull request.

Issues on GitHub

Pull requests

Release Workflow

This project uses a conventional commit-based release workflow that automatically creates releases when pull requests are merged to the main branch.

Branch Naming Convention

To trigger a release, your branch name must follow the conventional commit format:

What Happens on Merge

When a pull request with a conventional commit branch name is merged to main:

  1. Version Detection: The workflow analyzes the branch name to determine the version bump type
  2. Version Update: Updates the version in mix.exs according to semantic versioning
  3. Changelog Update:
    • Creates a new version entry in CHANGELOG.md
    • Adds the PR title to the appropriate section (Added/Changed/Fixed)
  4. Release Tag: Creates and pushes a git tag with the new version
  5. Commit: Commits the version and changelog changes to main

Skipping Releases

If your branch doesn't follow the conventional commit naming pattern, no release will be created. This is useful for:

Manual Releases

For manual releases or when the automated workflow isn't suitable, you can:

  1. Create a branch with the appropriate prefix (e.g., patch/manual-release)
  2. Make your changes
  3. Create a pull request
  4. Merge to trigger the release

License

Apache 2.0