EctoCooler
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:
-
Boilerplate functions for CRUD access, for every
Ecto.Schemareferenced in that context, introduce more noise than signal. This can obscure the more interesting details of the context. - These functions may tend to drift from the standard API by inviting edits for new use-cases, reducing the usefulness of naming conventions.
- The burden of locally testing wrapper functions yields low value compared to the investment in writing and maintaining them.
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
Generate CRUD functions for a given
Ecto.RepoandEcto.SchemaEctoCoolercan be used to generate CRUD functions for a givenEcto.RepoandEcto.Schema. By default it will create every function needed to create, read, update, and delete the resource. It includes the!variant of each function (where relevant) that raises on failure rather than returning an error tuple.
Allow customization of generated resources
-
You can optionally include or exclude specific functions to generate exactly the functions your context requires. There are also handy aliases (
:read,:read_write,:write,:delete) for quickly generating common subsets of functions.
-
You can optionally include or exclude specific functions to generate exactly the functions your context requires. There are also handy aliases (
Automatic pluralization
EctoCoolerusesDropsInflectorwhen generating functions to create readable english function names automatically. For example, given the schemaPerson, a function namedall_people/1is generated.
Generate documentation for each generated function
- Every function generated includes documentation so your application's documentation will include the generated functions with examples.
Supports any module
-
While
EctoCoolerwas designed for Phoenix Contexts in mind, It can be used in any Elixir module to access Ecto-based back-ends.
-
While
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"}
]
endConfiguration
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_appConfiguration 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:
MyApp.Repo.Posts.all/1MyApp.Repo.Posts.change/2MyApp.Repo.Posts.changeset/0MyApp.Repo.Posts.create/1MyApp.Repo.Posts.create!/1MyApp.Repo.Posts.delete/1MyApp.Repo.Posts.delete!/1MyApp.Repo.Posts.get/2MyApp.Repo.Posts.get!/2MyApp.Repo.Posts.get_by/2MyApp.Repo.Posts.get_by!/2MyApp.Repo.Posts.update/2MyApp.Repo.Posts.update!/2
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:
MyApp.Repo.Posts.all_posts/1MyApp.Repo.Posts.change_post/2MyApp.Repo.Posts.post_changeset/0MyApp.Repo.Posts.create_post/1MyApp.Repo.Posts.create_post!/1MyApp.Repo.Posts.delete_post/1MyApp.Repo.Posts.delete_post!/1MyApp.Repo.Posts.get_post/2MyApp.Repo.Posts.get_post!/2MyApp.Repo.Posts.get_post_by/2MyApp.Repo.Posts.get_post_by!/2MyApp.Repo.Posts.update_post/2MyApp.Repo.Posts.update_post!/2
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
endThis generates only the given functions:
MyApp.Repo.Posts.create/1MyApp.Repo.Posts.delete!/1
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
endThis generates all the functions excluding the given functions:
MyApp.Repo.Posts.all/1MyApp.Repo.Posts.change/2MyApp.Repo.Posts.changeset/0MyApp.Repo.Posts.create!/1MyApp.Repo.Posts.delete/1MyApp.Repo.Posts.get/2MyApp.Repo.Posts.get!/2MyApp.Repo.Posts.get_by/2MyApp.Repo.Posts.get_by!/2MyApp.Repo.Posts.update/2MyApp.Repo.Posts.update!/2
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
endThis generates all the functions necessary for reading data:
MyApp.Repo.Posts.all/1MyApp.Repo.Posts.get/2MyApp.Repo.Posts.get!/2MyApp.Repo.Posts.get_by/2MyApp.Repo.Posts.get_by!/2
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:
MyApp.Repo.Posts.all/1MyApp.Repo.Posts.change/2MyApp.Repo.Posts.changeset/0MyApp.Repo.Posts.create/1MyApp.Repo.Posts.create!/1MyApp.Repo.Posts.get/2MyApp.Repo.Posts.get!/2MyApp.Repo.Posts.get_by/2MyApp.Repo.Posts.get_by!/2MyApp.Repo.Posts.update/2MyApp.Repo.Posts.update!/2
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
endThis generates all the functions that modify data, excluding read-only functions:
MyApp.Repo.Posts.change/2MyApp.Repo.Posts.changeset/0MyApp.Repo.Posts.create/1MyApp.Repo.Posts.create!/1MyApp.Repo.Posts.update/2MyApp.Repo.Posts.update!/2MyApp.Repo.Posts.delete/1MyApp.Repo.Posts.delete!/1
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
endGenerated functions:
MyApp.Repo.Posts.delete/1MyApp.Repo.Posts.delete!/1
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:
preloads- a list of associations to preloadorder_by- an Ecto order_by clausewhere- a keyword list of field/value pairs to filter by
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'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'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:stringThis 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
endlib/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
endpriv/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
endmix 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:stringThis creates two files:
lib/my_app/schema/post.ex— Ecto schemapriv/repo/migrations/TIMESTAMP_create_posts.exs— Ecto migration
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:stringThis creates:
priv/repo/migrations/TIMESTAMP_create_posts.exs— Ecto migration
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.PostsOutput:
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!/2Attribute format
All generators accept attributes in the format field:type. Available formats:
| Format | Example | Description |
|---|---|---|
field:type | title:string |
Standard field with null: false in migration, included in validate_required |
field:type:null | bio:string:null |
Nullable field — omits null: false in migration, excluded from validate_required |
field:references:table | user_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.
Pull requests
- Fork it (https://github.com/daytonn/ecto_cooler/fork)
-
Add upstream remote (
git remote add upstream git@github.com:daytonn/ecto_cooler.git) -
Make sure you're up-to-date with upstream main (
git pull upstream main) -
Create your feature branch (
git checkout -b feature/fooBar) -
Commit your changes (
git commit -am 'Add some fooBar') -
Push to the branch (
git push origin feature/fooBar) - Create a new Pull Request
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:
- Major version bump:
major/descriptionorbreaking/description-
Example:
major/breaking-api-changes
-
Example:
- Minor version bump:
minor/descriptionorfeature/description-
Example:
feature/add-new-generator
-
Example:
- Patch version bump:
patch/description,fix/description,bugfix/description, orhotfix/description-
Example:
fix/resolve-version-parsing-issue
-
Example:
What Happens on Merge
When a pull request with a conventional commit branch name is merged to main:
- Version Detection: The workflow analyzes the branch name to determine the version bump type
- Version Update: Updates the version in
mix.exsaccording to semantic versioning - Changelog Update:
-
Creates a new version entry in
CHANGELOG.md - Adds the PR title to the appropriate section (Added/Changed/Fixed)
-
Creates a new version entry in
- Release Tag: Creates and pushes a git tag with the new version
- 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:
- Documentation updates
- Test improvements
- CI/CD changes
- Any changes that don't warrant a version bump
Manual Releases
For manual releases or when the automated workflow isn't suitable, you can:
-
Create a branch with the appropriate prefix (e.g.,
patch/manual-release) - Make your changes
- Create a pull request
- Merge to trigger the release